From a9fbfc8a5301ef2e64505b2a7b54b817272d89e4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Apr 2026 13:34:48 +0400 Subject: [PATCH 001/206] Updated on 2026-08-14 --- ...ccountCryptoPortfolioItemStateConverter.kt | 45 +++- .../tokenlist/state/TokensListItemUM.kt | 6 + .../wallets/usecase/GetWalletsUseCase.kt | 12 +- .../converter/ChooseTokenListItemConverter.kt | 37 +-- .../impl/model/ChooseTokenModel.kt | 54 ++--- .../choosetoken/impl/ui/ChooseTokenScreen.kt | 215 ++++++++++++++---- .../swap/choosetoken/impl/ui/ChooseTokenUM.kt | 10 +- 7 files changed, 272 insertions(+), 107 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt index 1955e4b9a0..9780c3831c 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt @@ -27,6 +27,8 @@ class AccountCryptoPortfolioItemStateConverter( private val priceChangeLce: Lce? = null, private val onItemClick: ((Account.CryptoPortfolio) -> Unit)? = null, private val onItemLongClick: ((Account.CryptoPortfolio) -> Unit)? = null, + private val fiatAmountStateProvider: ((TotalFiatBalance) -> FiatAmountState?)? = null, + private val subtitle2StateProvider: ((Lce) -> Subtitle2State?)? = null, ) : Converter { override fun convert(value: TotalFiatBalance): TokenItemState { @@ -40,11 +42,11 @@ class AccountCryptoPortfolioItemStateConverter( private fun Account.CryptoPortfolio.mapToContentState( fiatBalance: TotalFiatBalance.Loaded, ): TokenItemState.Content { - val subtitle2State = priceChangeLce?.fold( - ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, - ifError = { null }, - ifContent = { priceChange -> priceChange.toSubtitle2State() }, - ) + val subtitle2State = if (priceChangeLce != null && subtitle2StateProvider != null) { + subtitle2StateProvider(priceChangeLce) + } else { + createSubtitle2State(priceChangeLce) + } return TokenItemState.Content( id = account.accountId.toItemId(), iconState = AccountIconItemStateConverter().convert(this), @@ -59,11 +61,8 @@ class AccountCryptoPortfolioItemStateConverter( ), isAvailable = false, ), - fiatAmountState = FiatAmountState.Content( - text = fiatBalance.amount - .format { fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol) }, - isFlickering = fiatBalance.source == StatusSource.CACHE, - ), + fiatAmountState = fiatAmountStateProvider?.invoke(fiatBalance) + ?: createFiatAmountState(fiatBalance, appCurrency), subtitle2State = subtitle2State, onItemClick = onItemClick?.let { onItemClick -> { onItemClick(account) } }, onItemLongClick = onItemLongClick?.let { onItemLongClick -> { onItemLongClick(account) } }, @@ -127,4 +126,30 @@ class AccountCryptoPortfolioItemStateConverter( type = this.value.getPriceChangeType(), isFlickering = this.source.isFlickering(), ) + + private fun createFiatAmountState(fiatBalance: TotalFiatBalance, appCurrency: AppCurrency): FiatAmountState { + return when (fiatBalance) { + TotalFiatBalance.Failed, + TotalFiatBalance.Loading, + -> FiatAmountState.Empty + + is TotalFiatBalance.Loaded -> FiatAmountState.Content( + text = fiatBalance.amount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + isFlickering = fiatBalance.source == StatusSource.CACHE, + ) + } + } + + private fun createSubtitle2State(priceChangeLce: Lce?): Subtitle2State? { + return priceChangeLce?.fold( + ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, + ifError = { null }, + ifContent = { priceChange -> priceChange.toSubtitle2State() }, + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt index c09e64697b..f5c2277c35 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/state/TokensListItemUM.kt @@ -55,6 +55,12 @@ sealed interface TokensListItemUM { is PortfolioItemContentUM.Tokens -> content.tokens is PortfolioItemContentUM.Empty -> persistentListOf() } + + val tokensItemsList: List + get() = when (content) { + is PortfolioItemContentUM.Tokens -> content.tokens.filterIsInstance() + is PortfolioItemContentUM.Empty -> emptyList() + } } data class Text(override val id: Any, val text: TextReference) : TokensListItemUM diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt index 1a436c9f93..355ce124c1 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetWalletsUseCase.kt @@ -3,9 +3,9 @@ package com.tangem.domain.wallets.usecase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import java.util.LinkedHashMap /** * Use case for getting list of user wallets @@ -22,9 +22,13 @@ class GetWalletsUseCase( operator fun invoke(): Flow> = userWalletsListRepository.userWallets.map { requireNotNull(it) } @Throws(IllegalArgumentException::class) - fun invokeAsMap(): Flow> = userWalletsListRepository.userWallets - .map { requireNotNull(it) } - .map { wallets -> + fun invokeAsMap(isOnlyMultiCurrency: Boolean = true): Flow> = invoke() + .map { list -> + val wallets = if (isOnlyMultiCurrency) { + list.filter { wallet -> wallet.isMultiCurrency } + } else { + list + } wallets.associateByTo( destination = linkedMapOf(), keySelector = { wallet -> wallet.walletId }, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index a130a0eabf..ee77c4eeec 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -6,9 +6,12 @@ import com.tangem.common.ui.tokens.TokenConverterParams import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio @@ -17,6 +20,7 @@ import com.tangem.domain.models.tokenlist.TokenList import com.tangem.feature.swap.choosetoken.impl.model.ClickIntents import com.tangem.feature.swap.choosetoken.impl.model.isSearchingState import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.toPersistentList internal class ChooseTokenListItemConverter( @@ -57,10 +61,9 @@ internal class ChooseTokenListItemConverter( if (accountItems.isEmpty()) { return TokenListUMData.EmptyList } - val accountsList = accountItems.toPersistentList() return TokenListUMData.AccountList( - tokensList = accountsList, - totalTokensCount = accountsList.size, + tokensList = accountItems.toPersistentList(), + totalTokensCount = accountItems.sumOf { portfolio -> portfolio.tokensItemsList.size }, ) } @@ -77,11 +80,19 @@ internal class ChooseTokenListItemConverter( clickIntents.onAccountExpandClick(clickedAccount) } } + val fiatAmountStateProvider: ((TotalFiatBalance) -> FiatAmountState?) = if (isSearchingState) { + { _ -> FiatAmountState.Empty } + } else { + { _ -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative) } + } + val converter = AccountCryptoPortfolioItemStateConverter( appCurrency = appCurrency, account = account, onItemClick = onItemClick.takeIf { !isSearchingState }, priceChangeLce = this.priceChangeLce, + fiatAmountStateProvider = fiatAmountStateProvider, + subtitle2StateProvider = { _ -> null }, ) val accountItem = converter.convert(tokenList.totalFiatBalance) val tokenConverter = tokenStatusConverter(this) @@ -100,18 +111,14 @@ internal class ChooseTokenListItemConverter( return when (tokenList) { is TokenList.Empty -> TokenListUMData.EmptyList - is TokenList.GroupedByNetwork -> tokenList.toGroupedItems(tokenConverter).let { grouped -> - TokenListUMData.TokenList( - tokensList = grouped.toPersistentList(), - totalTokensCount = grouped.size, - ) - } - is TokenList.Ungrouped -> tokenList.toUngroupedItems(tokenConverter).let { ungrouped -> - TokenListUMData.TokenList( - tokensList = ungrouped.toPersistentList(), - totalTokensCount = ungrouped.size, - ) - } + is TokenList.GroupedByNetwork -> TokenListUMData.TokenList( + tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(), + totalTokensCount = tokenList.flattenCurrencies().size, + ) + is TokenList.Ungrouped -> TokenListUMData.TokenList( + tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(), + totalTokensCount = tokenList.flattenCurrencies().size, + ) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt index 2eb003236d..ca32af6c5e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt @@ -5,8 +5,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.AccountId @@ -19,13 +18,9 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.choosetoken.api.* import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarToggleTransformer import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarUpdateQueryTransformer -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenFullUM -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenUM -import com.tangem.feature.swap.choosetoken.impl.ui.WalletListUM +import com.tangem.feature.swap.choosetoken.impl.ui.* import com.tangem.feature.swap.converters.TokensDataConverter import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent @@ -147,41 +142,39 @@ internal class ChooseTokenModel @Inject constructor( @Suppress("LongMethod") private fun combineUI(): StateFlow = channelFlow { - val allWalletsFlow: StateFlow> = + val allWalletsFlow: StateFlow> = getWalletsUseCase.invokeAsMap().stateIn(this) + // todo swap add optional param, store, and GetSelectedWalletUseCase + val firstSelectedWallet = allWalletsFlow.value.values.first() val selectedWalletFlow: StateFlow = onWalletSelected.receiveAsFlow() .mapNotNull { walletId -> allWalletsFlow.value[walletId] } - .stateIn(this, SharingStarted.Eagerly, allWalletsFlow.value.values.first()) + .stateIn(this, SharingStarted.Eagerly, firstSelectedWallet) - val selectedWalletTokensData: Flow = combine( - flow = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), + val fullPortfolioBlockFlow = combine( + flow = allWalletsFlow, flow2 = portfolioListBlockDelegate.portfolioList, - transform = { selectedWalletId, allPortfoliosData -> allPortfoliosData[selectedWalletId] }, - ) - .filterNotNull() - .distinctUntilChanged() - - val walletListUmFlow = combine( - flow = selectedWalletFlow, - flow2 = allWalletsFlow, - transform = { selectedWallet, allWallets -> - allWallets.entries + flow3 = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), + transform = { allWallets, portfolioList, selectedWalletId -> + val tokensListData = portfolioList[selectedWalletId] ?: return@combine null + val walletsUM = allWallets.entries .map { (walletId, wallet) -> - val type = if (selectedWallet.walletId == walletId) { - TangemButtonType.Primary - } else { - TangemButtonType.Secondary - } - TangemButtonUM( + val searchResultCount: TextReference? = portfolioList[walletId]?.totalTokensCount + ?.toString() + ?.let(::stringReference) + ?.takeIf { isSearchingState } + WalletTabUM( text = stringReference(wallet.name), onClick = { onWalletSelected.trySend(walletId) }, - type = type, + isSelected = selectedWalletId == walletId, + count = searchResultCount, ) } + walletsUM to tokensListData }, ) + .filterNotNull() .distinctUntilChanged() portfolioListBlockDelegate.onTokenItemClick.receiveAsFlow() @@ -195,11 +188,10 @@ internal class ChooseTokenModel @Inject constructor( .launchIn(this) combine( - flow = selectedWalletTokensData, + flow = fullPortfolioBlockFlow, flow2 = settingContextUseCase.invoke(), flow3 = marketsStateFlow, - flow4 = walletListUmFlow, - transform = { tokensData, settings, marketsData, walletList -> + transform = { (walletList, tokensData), settings, marketsData -> val walletsUM = if (walletList.size != 1) { WalletListUM(walletList.toPersistentList()) } else { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt index 626a24fe4e..a02d3a52c2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt @@ -1,15 +1,23 @@ package com.tangem.feature.swap.choosetoken.impl.ui +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -17,10 +25,6 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.tokens.portfolioTokensList import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.components.buttons.common.TangemButton -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition -import com.tangem.core.ui.components.buttons.common.TangemButtonSize -import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.TangemSearchBarDefaults @@ -34,8 +38,6 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -54,6 +56,18 @@ import kotlin.random.Random private const val LOAD_MORE_BUFFER = 25 +private val ChooseTokenUM.isNotFoundState: Boolean + get() = tokensListData.tokensList.isEmpty() && + isSearching && + marketsState !is SwapMarketState.Content && + marketsState !is SwapMarketState.Loading + +private val ChooseTokenUM.isEmptyState: Boolean + get() = tokensListData.tokensList.isEmpty() && + !isSearching && + marketsState !is SwapMarketState.Content && + marketsState !is SwapMarketState.Loading + @Composable internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { Column( @@ -93,7 +107,6 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { modifier = modifier .fillMaxSize() .nestedScroll(nestedScrollConnection), - horizontalAlignment = Alignment.CenterHorizontally, state = lazyListState, contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) { @@ -108,20 +121,26 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { assetsTitle() if (state.contentUM != null) { - walletListItem(state.contentUM.walletList) + when { + state.contentUM.isNotFoundState -> tokensNotFound() + state.contentUM.isEmptyState -> emptyTokensList() + else -> { + walletListItem(state.contentUM.walletList) - tokensListItems( - tokensListData = state.contentUM.tokensListData, - isBalanceHidden = state.contentUM.isBalanceHidden, - ) + tokensListItems( + tokensListData = state.contentUM.tokensListData, + isBalanceHidden = state.contentUM.isBalanceHidden, + ) - if (state.contentUM.marketsState != null) { - item("markets_title_spacer") { SpacerH(height = 20.dp) } - swapMarketsListItems(state.contentUM.marketsState) + if (state.contentUM.marketsState != null) { + item("markets_title_spacer") { SpacerH(height = 20.dp) } + swapMarketsListItems(state.contentUM.marketsState) + } + } } } } - if (state.contentUM?.marketsState != null) { + if (state.contentUM?.marketsState != null && !state.contentUM.isNotFoundState && !state.contentUM.isEmptyState) { SetupMarketScrollTracker(state.contentUM.marketsState, lazyListState) } } @@ -183,26 +202,59 @@ private fun LazyListScope.assetsTitle() { private fun LazyListScope.walletListItem(walletList: WalletListUM) { if (walletList.items.isEmpty()) return item("wallet_list") { - Row( - modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .fillMaxWidth(), + LazyRow( + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8), - verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(horizontal = 16.dp), ) { - walletList.items.forEach { um -> - val colors = when (um.type) { - TangemButtonType.Primary -> TangemButtonsDefaults.primaryButtonColors - else -> TangemButtonsDefaults.secondaryButtonColors - } - TangemButton( - text = um.text?.resolveReference().orEmpty(), - icon = TangemButtonIconPosition.None, - size = TangemButtonSize.Action, - colors = colors, - showProgress = false, - onClick = um.onClick, - enabled = true, + items(walletList.items) { um -> + WalletTabItem(um) + } + } + } +} + +@Composable +private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { + val isSelected = state.isSelected + val backgroundColor = if (isSelected) TangemTheme.colors.button.primary else TangemTheme.colors.button.secondary + val buttonTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1 + val countTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.secondary + val countBackground = if (isSelected) { + TangemTheme.colors.button.secondary.copy(alpha = 0.2f) + } else { + TangemTheme.colors.button.primary.copy(alpha = 0.1f) + } + + Row( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .clickable(onClick = state.onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.text.resolveReference(), + color = buttonTextColor, + style = TangemTheme.typography.button, + ) + + if (state.count != null) { + Spacer(modifier = Modifier.width(8.dp)) + + Box( + modifier = Modifier + .background(countBackground, shape = CircleShape) + .defaultMinSize(minWidth = 20.dp) + .padding(horizontal = 4.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = state.count.resolveReference(), + color = countTextColor, + style = TangemTheme.typography.caption1, ) } } @@ -253,6 +305,58 @@ private fun LazyListScope.tokensList(items: ImmutableList, isB ) } +private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) { + item("EmptyTokensList") { + Box( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillParentMaxSize(), + ) { + Column(modifier = Modifier.align(Alignment.Center)) { + Image( + modifier = Modifier + .size(TangemTheme.dimens.size64) + .align(Alignment.CenterHorizontally), + painter = painterResource(id = R.drawable.ic_no_token_44), + colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), + contentDescription = null, + ) + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing30) + .align(Alignment.CenterHorizontally), + text = stringResourceSafe(id = R.string.exchange_tokens_empty_tokens), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } + } + } +} + +private fun LazyListScope.tokensNotFound(modifier: Modifier = Modifier) { + item("TokensNotFound") { + Box( + modifier = modifier + .background(TangemTheme.colors.background.secondary) + .fillParentMaxSize(), + ) { + Text( + modifier = Modifier + .padding(top = TangemTheme.dimens.spacing32) + .padding(horizontal = TangemTheme.dimens.spacing30) + .align(Alignment.TopCenter), + text = stringResourceSafe(id = R.string.express_token_list_empty_search), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + } + } +} + @Preview @Composable private fun TokenScreenPreview(@PreviewParameter(ChooseTokenScreenPreviewProvider::class) state: ChooseTokenFullUM) { @@ -321,31 +425,42 @@ private val accounts private val wallets get() = persistentListOf( - TangemButtonUM( + WalletTabUM( text = TextReference.Str(value = "Wallet 1"), - type = TangemButtonType.Primary, + isSelected = true, onClick = {}, + count = null, ), - TangemButtonUM( + WalletTabUM( + text = TextReference.Str(value = "Wallet 1"), + isSelected = true, + onClick = {}, + count = stringReference("3"), + ), + WalletTabUM( text = TextReference.Str(value = "Wallet 2"), - type = TangemButtonType.Secondary, + isSelected = false, onClick = {}, + count = stringReference("333"), ), - TangemButtonUM( + WalletTabUM( text = TextReference.Str(value = "Wallet 3"), - type = TangemButtonType.Secondary, + isSelected = false, onClick = {}, + count = null, ), ) +private val initialUM = ChooseTokenInitialUM( + screenTitle = stringReference("Choose token"), + onCloseClick = {}, + searchBar = searchBar, +) + private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( ChooseTokenFullUM( - initialUM = ChooseTokenInitialUM( - screenTitle = stringReference("Choose token"), - onCloseClick = {}, - searchBar = searchBar, - ), + initialUM = initialUM, contentUM = ChooseTokenUM( walletList = WalletListUM(wallets), isBalanceHidden = false, @@ -357,5 +472,15 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider, + val items: ImmutableList, +) + +internal data class WalletTabUM( + val text: TextReference, + val count: TextReference?, + val isSelected: Boolean, + val onClick: () -> Unit, ) \ No newline at end of file From 52a607c1f5f4e479be9bb9dd65bc78d18f1eb540 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 2 Apr 2026 19:19:32 +0400 Subject: [PATCH 002/206] Updated on 2026-08-14 --- .../tap/di/domain/StakingDomainModule.kt | 11 +++- .../staking/DefaultP2PEthPoolRepository.kt | 3 +- .../data/staking/DefaultStakingRepository.kt | 38 +++--------- .../data/staking/di/StakingDataModule.kt | 2 +- .../toggles/DefaultStakingFeatureToggles.kt | 27 +++++++- .../DefaultStakingFeatureTogglesTest.kt | 61 +++++++++++++++++++ .../tangem/domain/staking/StakingIdFactory.kt | 8 ++- .../staking/toggles/StakingFeatureToggles.kt | 5 +- .../domain/staking/StakingIdFactoryTest.kt | 38 +++++++++++- 9 files changed, 153 insertions(+), 40 deletions(-) create mode 100644 data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt index a823079f33..5633c8744b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/StakingDomainModule.kt @@ -2,6 +2,7 @@ package com.tangem.tap.di.domain import com.tangem.domain.staking.* import com.tangem.domain.staking.repositories.* +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.staking.single.SingleStakingBalanceFetcher import com.tangem.domain.staking.usecase.StakingAvailabilityListUseCase import com.tangem.domain.walletmanager.WalletManagersFacade @@ -226,8 +227,14 @@ internal object StakingDomainModule { @Provides @Singleton - fun provideStakingIdFactory(walletManagersFacade: WalletManagersFacade): StakingIdFactory { - return StakingIdFactory(walletManagersFacade = walletManagersFacade) + fun provideStakingIdFactory( + walletManagersFacade: WalletManagersFacade, + stakingFeatureToggles: StakingFeatureToggles, + ): StakingIdFactory { + return StakingIdFactory( + walletManagersFacade = walletManagersFacade, + stakingFeatureToggles = stakingFeatureToggles, + ) } @Provides diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt index b189675a38..1403dcdaf9 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultP2PEthPoolRepository.kt @@ -22,6 +22,7 @@ import com.tangem.domain.staking.model.ethpool.P2PEthPoolUnsignedTx import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -65,7 +66,7 @@ internal class DefaultP2PEthPoolRepository( } override suspend fun fetchVaults(network: P2PEthPoolNetwork) { - val vaults = if (stakingFeatureToggles.isEthStakingEnabled) { + val vaults = if (stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)) { getVaults(network).getOrElse { error -> TangemLogger.e("Error fetching P2PEthPool vaults: $error") emptyList() diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt index b46d6ea594..f98c982fa6 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakingRepository.kt @@ -1,8 +1,6 @@ package com.tangem.data.staking import arrow.core.getOrElse -import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.domain.card.common.TapWorkarounds.isWallet2 import com.tangem.domain.models.currency.CryptoCurrency @@ -22,14 +20,13 @@ import com.tangem.lib.crypto.BlockchainUtils.isSolana import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.flowOf + import kotlinx.coroutines.withContext -@Suppress("LargeClass", "LongParameterList", "TooManyFunctions") internal class DefaultStakingRepository( private val stakeKitRepository: StakeKitRepository, private val p2pEthPoolRepository: P2PEthPoolRepository, - private val stakingBalanceStoreV2: StakeKitBalancesStore, + private val stakeKitBalancesStore: StakeKitBalancesStore, private val dispatchers: CoroutineDispatcherProvider, private val getUserWalletUseCase: GetUserWalletUseCase, private val stakingFeatureToggles: StakingFeatureToggles, @@ -40,7 +37,8 @@ internal class DefaultStakingRepository( cryptoCurrency: CryptoCurrency, ): Flow { return channelFlow { - if (!checkFeatureToggleEnabled(cryptoCurrency)) { + val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) + if (stakingIntegration == null || !stakingFeatureToggles.isIntegrationEnabled(stakingIntegration)) { send(StakingAvailability.Unavailable) return@channelFlow } @@ -56,8 +54,6 @@ internal class DefaultStakingRepository( return@channelFlow } - val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) - val availabilityFlow = when (stakingIntegration) { StakingIntegrationID.P2PEthPool -> p2pEthPoolRepository.getStakingAvailability() is StakingIntegrationID.StakeKit -> stakeKitRepository.getStakingAvailability( @@ -65,7 +61,6 @@ internal class DefaultStakingRepository( rawCurrencyId, cryptoCurrency.symbol, ) - null -> flowOf(StakingAvailability.Unavailable) } availabilityFlow.collect { send(it) } @@ -76,20 +71,15 @@ internal class DefaultStakingRepository( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): StakingAvailability { - if (!checkFeatureToggleEnabled(cryptoCurrency)) { - return StakingAvailability.Unavailable - } + val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) + ?.takeIf(stakingFeatureToggles::isIntegrationEnabled) + ?: return StakingAvailability.Unavailable if (checkForInvalidCardBatch(userWalletId, cryptoCurrency)) { return StakingAvailability.Unavailable } val rawCurrencyId = cryptoCurrency.id.rawCurrencyId - if (rawCurrencyId == null) { - return StakingAvailability.Unavailable - } - - val stakingIntegration = StakingIntegrationID.create(currencyId = cryptoCurrency.id) ?: return StakingAvailability.Unavailable return when (stakingIntegration) { @@ -104,7 +94,7 @@ internal class DefaultStakingRepository( override suspend fun isAnyTokenStaked(userWalletId: UserWalletId): Boolean { return withContext(dispatchers.default) { - val balances = stakingBalanceStoreV2.getAllSyncOrNull(userWalletId) ?: return@withContext false + val balances = stakeKitBalancesStore.getAllSyncOrNull(userWalletId) ?: return@withContext false val hasDataStakingBalance by lazy { balances.any { stakingBalance -> @@ -116,18 +106,6 @@ internal class DefaultStakingRepository( } } - private fun checkFeatureToggleEnabled(cryptoCurrency: CryptoCurrency): Boolean { - return when (cryptoCurrency.network.id.toBlockchain()) { - Blockchain.Ethereum -> { - when (cryptoCurrency) { - is CryptoCurrency.Coin -> stakingFeatureToggles.isEthStakingEnabled - is CryptoCurrency.Token -> true - } - } - else -> true - } - } - private fun checkForInvalidCardBatch(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): Boolean { val userWallet = getUserWalletUseCase(userWalletId).getOrElse { error("Failed to get user wallet") diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt index 0aa8abf1b5..a997cbce26 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingDataModule.kt @@ -65,7 +65,7 @@ internal object StakingDataModule { return DefaultStakingRepository( stakeKitRepository = stakeKitRepository, p2pEthPoolRepository = p2pEthPoolRepository, - stakingBalanceStoreV2 = stakeKitBalancesStore, + stakeKitBalancesStore = stakeKitBalancesStore, dispatchers = dispatchers, getUserWalletUseCase = getUserWalletUseCase, stakingFeatureToggles = stakingFeatureToggles, diff --git a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt index 011cc73bf9..6f62c8bc06 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt @@ -2,12 +2,35 @@ package com.tangem.data.staking.toggles import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.toggles.StakingFeatureToggles internal class DefaultStakingFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, ) : StakingFeatureToggles { - override val isEthStakingEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) + override fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean { + val toggle = integrationId.getFeatureToggle() ?: return true + return featureTogglesManager.isFeatureEnabled(toggle) + } + + private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) { + is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED + is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle() + } + + private fun StakingIntegrationID.StakeKit.getStakeKitFeatureToggle(): FeatureToggles? = when (this) { + is StakingIntegrationID.StakeKit.Coin -> when (this) { + StakingIntegrationID.StakeKit.Coin.Ton, + StakingIntegrationID.StakeKit.Coin.Solana, + StakingIntegrationID.StakeKit.Coin.Cosmos, + StakingIntegrationID.StakeKit.Coin.Tron, + StakingIntegrationID.StakeKit.Coin.BSC, + StakingIntegrationID.StakeKit.Coin.Cardano, + -> null + } + is StakingIntegrationID.StakeKit.EthereumToken -> when (this) { + StakingIntegrationID.StakeKit.EthereumToken.Polygon -> null + } + } } \ No newline at end of file diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt new file mode 100644 index 0000000000..ca6766c8c8 --- /dev/null +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt @@ -0,0 +1,61 @@ +package com.tangem.data.staking.toggles + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.staking.model.StakingIntegrationID +import com.google.common.truth.Truth.assertThat +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultStakingFeatureTogglesTest { + + private val featureTogglesManager: FeatureTogglesManager = mockk() + private val toggles = DefaultStakingFeatureToggles(featureTogglesManager = featureTogglesManager) + + @BeforeEach + fun resetMocks() { + clearMocks(featureTogglesManager) + } + + @Test + fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() { + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true + + assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue() + + verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } + } + + @Test + fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() { + every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false + + assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse() + + verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } + } + + @Test + fun `existing StakeKit Coin integrations are always enabled`() { + StakingIntegrationID.StakeKit.Coin.entries.forEach { coin -> + assertThat(toggles.isIntegrationEnabled(coin)).isTrue() + } + + verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) } + } + + @Test + fun `existing StakeKit EthereumToken integrations are always enabled`() { + StakingIntegrationID.StakeKit.EthereumToken.entries.forEach { token -> + assertThat(toggles.isIntegrationEnabled(token)).isTrue() + } + + verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) } + } +} \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt b/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt index c8648ad64c..6fb6297f3f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/StakingIdFactory.kt @@ -2,23 +2,27 @@ package com.tangem.domain.staking import arrow.core.Either import arrow.core.raise.either +import arrow.core.raise.ensure import arrow.core.raise.ensureNotNull import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.staking.StakingID import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.walletmanager.WalletManagersFacade /** * Factory class for creating instances of [StakingID] * - * @property walletManagersFacade wallet manager facade + * @property walletManagersFacade wallet manager facade + * @property stakingFeatureToggles staking feature toggles * [REDACTED_AUTHOR] */ class StakingIdFactory( private val walletManagersFacade: WalletManagersFacade, + private val stakingFeatureToggles: StakingFeatureToggles, ) { /** @@ -72,6 +76,8 @@ class StakingIdFactory( ensureNotNull(integrationId) { Error.UnsupportedCurrency } + ensure(stakingFeatureToggles.isIntegrationEnabled(integrationId)) { Error.UnsupportedCurrency } + val address = defaultAddressProvider().takeUnless { it.isNullOrEmpty() } ensureNotNull(address) { Error.UnableToGetAddress(integrationId = integrationId) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt index 3553692065..80761562fc 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/toggles/StakingFeatureToggles.kt @@ -1,5 +1,8 @@ package com.tangem.domain.staking.toggles +import com.tangem.domain.staking.model.StakingIntegrationID + interface StakingFeatureToggles { - val isEthStakingEnabled: Boolean + + fun isIntegrationEnabled(integrationId: StakingIntegrationID): Boolean } \ No newline at end of file diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt index 71a5d271ed..3211cdff64 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt @@ -10,11 +10,13 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.test.core.ProvideTestModels import io.mockk.clearMocks 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.BeforeEach @@ -30,11 +32,16 @@ import org.junit.jupiter.params.ParameterizedTest internal class StakingIdFactoryTest { private val walletManagersFacade: WalletManagersFacade = mockk() - private val factory = StakingIdFactory(walletManagersFacade = walletManagersFacade) + private val stakingFeatureToggles: StakingFeatureToggles = mockk() + private val factory = StakingIdFactory( + walletManagersFacade = walletManagersFacade, + stakingFeatureToggles = stakingFeatureToggles, + ) @BeforeEach fun resetMocks() { - clearMocks(walletManagersFacade) + clearMocks(walletManagersFacade, stakingFeatureToggles) + every { stakingFeatureToggles.isIntegrationEnabled(any()) } returns true } @Nested @@ -66,6 +73,33 @@ internal class StakingIdFactoryTest { } } + @Test + fun `create returns UnsupportedCurrency if integration is disabled by toggle`() = runTest { + // Arrange + val userWalletId = UserWalletId(stringValue = "011") + val currency = MockCryptoCurrencyFactory().createCoin(Blockchain.TON) + + every { + stakingFeatureToggles.isIntegrationEnabled(StakingIntegrationID.StakeKit.Coin.Ton) + } returns false + + // Act + val actual = factory.create( + userWalletId = userWalletId, + currencyId = currency.id, + network = currency.network, + ) + + // Assert + val expected = StakingIdFactory.Error.UnsupportedCurrency + + Truth.assertThat(actual.leftOrNull()).isEqualTo(expected) + + coVerify(inverse = true) { + walletManagersFacade.getDefaultAddress(userWalletId = any(), network = any()) + } + } + @Test fun `create returns UnableToGetAddress if address is null`() = runTest { // Arrange From 9944036c4d2a6eae5d1dd986f87d3bcc8a1a9e87 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Apr 2026 14:09:57 +0300 Subject: [PATCH 003/206] Updated on 2026-08-14 --- .../di/domain/DynamicAddressesDomainModule.kt | 78 ++++++++ .../tap/domain/twins/FinalizeTwinTask.kt | 2 +- ...dynamic_addresses_bottomsheet_check_24.xml | 10 + ...namic_addresses_bottomsheet_enable_top.xml | 18 ++ ...dresses_bottomsheet_enable_unavailable.xml | 19 ++ ...dynamic_addresses_bottomsheet_flash_24.xml | 9 + data/dynamic-addresses/build.gradle.kts | 1 + .../DefaultDynamicAddressesRepository.kt | 69 ++++++- .../DefaultDerivationsRepository.kt | 18 ++ domain/dynamic-addresses/build.gradle.kts | 4 + .../DynamicAddressesSupportedBlockchains.kt | 53 ++++++ .../EnableDynamicAddressesError.kt | 8 + .../EnableDynamicAddressesUseCase.kt | 17 +- .../dynamicaddresses/IsXpubDerivedUseCase.kt | 37 ++++ .../IsXpubSupportedUseCase.kt | 22 +++ .../repository/DynamicAddressesRepository.kt | 3 + .../derivations/DerivationsRepository.kt | 3 + .../GetExtendedPublicKeyForCurrencyUseCase.kt | 45 ++--- features/tokendetails/impl/build.gradle.kts | 2 + .../DefaultTokenDetailsComponent.kt | 5 + .../model/DynamicAddressesDelegate.kt | 125 ++++++++++++ .../model/TokenDetailsClickIntents.kt | 4 + .../tokendetails/model/TokenDetailsModel.kt | 100 +++++++--- .../route/TokenDetailsBottomSheetConfig.kt | 3 + .../state/factory/TokenDetailsStateFactory.kt | 16 +- .../DynamicAddressesBottomSheetComponent.kt | 34 ++++ .../DynamicAddressesBottomSheet.kt | 28 +++ .../DynamicAddressesBottomSheetConfig.kt | 22 +++ .../DynamicAddressesBottomSheetContent.kt | 179 ++++++++++++++++++ 29 files changed, 874 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt create mode 100644 core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml create mode 100644 core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml create mode 100644 core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml create mode 100644 domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt create mode 100644 domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt create mode 100644 domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt create mode 100644 domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt new file mode 100644 index 0000000000..3b9ec07528 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt @@ -0,0 +1,78 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase +import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase +import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase +import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase +import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.derivations.DerivationsRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object DynamicAddressesDomainModule { + + @Provides + @Singleton + fun provideEnableDynamicAddressesUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): EnableDynamicAddressesUseCase { + return EnableDynamicAddressesUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideDisableDynamicAddressesUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): DisableDynamicAddressesUseCase { + return DisableDynamicAddressesUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideGetDynamicAddressesStatusUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): GetDynamicAddressesStatusUseCase { + return GetDynamicAddressesStatusUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideGetDynamicReceiveAddressUseCase( + dynamicAddressesRepository: DynamicAddressesRepository, + ): GetDynamicReceiveAddressUseCase { + return GetDynamicReceiveAddressUseCase(dynamicAddressesRepository) + } + + @Provides + @Singleton + fun provideCreateConsolidationTransactionUseCase( + consolidationRepository: ConsolidationRepository, + ): CreateConsolidationTransactionUseCase { + return CreateConsolidationTransactionUseCase(consolidationRepository) + } + + @Provides + @Singleton + fun provideIsXpubSupportedUseCase(walletManagersFacade: WalletManagersFacade): IsXpubSupportedUseCase { + return IsXpubSupportedUseCase(walletManagersFacade) + } + + @Provides + @Singleton + fun provideIsXpubDerivedUseCase( + walletManagersFacade: WalletManagersFacade, + derivationsRepository: DerivationsRepository, + ): IsXpubDerivedUseCase { + return IsXpubDerivedUseCase(walletManagersFacade, derivationsRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt index cc707e1fce..548fadc6ad 100644 --- a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt @@ -33,7 +33,7 @@ class FinalizeTwinTask( visaCardScanHandler = null, visaCoroutineScope = null, shouldCheckIsAlreadyActivated = false, - isDynamicAddressesEnabled = false, + isDynamicAddressesEnabled = isDynamicAddressesEnabled, onboardingV2FeatureToggles = null, ).run(session, callback) is CompletionResult.Failure -> diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml new file mode 100644 index 0000000000..fac7eb89dd --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_check_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml new file mode 100644 index 0000000000..a1e00c61f2 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_top.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml new file mode 100644 index 0000000000..57afa69fb6 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_enable_unavailable.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml new file mode 100644 index 0000000000..71ad60eda8 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_bottomsheet_flash_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/data/dynamic-addresses/build.gradle.kts b/data/dynamic-addresses/build.gradle.kts index 47a15f3e68..a504298e11 100644 --- a/data/dynamic-addresses/build.gradle.kts +++ b/data/dynamic-addresses/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { // region Project - Libs implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + implementation(tangemDeps.card.core) // endregion // region DI diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index 28c3c017b6..a9d2bcd8b7 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -1,5 +1,6 @@ package com.tangem.data.dynamicaddresses +import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.datasource.api.tangemTech.models.UserTokensResponse @@ -10,6 +11,7 @@ import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.blockchain.extensions.SimpleResult import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger @@ -31,17 +33,20 @@ internal class DefaultDynamicAddressesRepository( .map { response -> val token = response.findToken(network) when { - token?.dynamicAddressesEnabled == true -> DynamicAddressesStatus.ENABLED - else -> DynamicAddressesStatus.DISABLED + token?.dynamicAddressesEnabled != true -> DynamicAddressesStatus.DISABLED + !isXpubAvailable(userWalletId, network) -> DynamicAddressesStatus.ENABLED_REQUIRES_SETUP + else -> DynamicAddressesStatus.ENABLED } - // TODO handle ENABLED_REQUIRES_SETUP when XPUB is not derived locally } .flowOn(dispatchers.io) } override suspend fun enable(userWalletId: UserWalletId, network: Network, xpub: String) { withContext(dispatchers.io) { - walletManagersFacade.enableXpubMode(userWalletId, network, xpub) + val result = walletManagersFacade.enableXpubMode(userWalletId, network, xpub) + if (result is SimpleResult.Failure) { + error("Failed to enable xpub mode for $userWalletId / ${network.id}: ${result.error}") + } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } .onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) } @@ -50,7 +55,10 @@ internal class DefaultDynamicAddressesRepository( override suspend fun disable(userWalletId: UserWalletId, network: Network) { withContext(dispatchers.io) { - walletManagersFacade.disableXpubMode(userWalletId, network) + val result = walletManagersFacade.disableXpubMode(userWalletId, network) + if (result is SimpleResult.Failure) { + error("Failed to disable xpub mode for $userWalletId / ${network.id}: ${result.error}") + } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } .onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) } @@ -70,6 +78,45 @@ internal class DefaultDynamicAddressesRepository( return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network) } + override suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean { + return withContext(dispatchers.io) { + val response = walletAccountsFetcher.getSaved(userWalletId) ?: return@withContext false + val baseDerivationPath = network.derivationPath.value ?: return@withContext false + + response.accounts + .flatMap { it.tokens.orEmpty() } + .any { token -> + val tokenDerivationPath = token.derivationPath ?: return@any false + token.networkId == network.backendId && + tokenDerivationPath != baseDerivationPath && + hasNonZeroChangeOrIndex(tokenDerivationPath, baseDerivationPath) + } + } + } + + /** + * Checks if the token's derivation path has the same first 3 nodes (purpose/coin/account) + * as the base path but different change/index nodes (not both 0). + */ + private fun hasNonZeroChangeOrIndex(tokenPath: String, basePath: String): Boolean { + val tokenNodes = runCatching { DerivationPath(tokenPath).nodes }.getOrNull() ?: return false + val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false + + if (tokenNodes.size < DERIVATION_NODE_COUNT || baseNodes.size < DERIVATION_NODE_COUNT) return false + + // First 3 nodes must match (purpose/coin/account) by value, ignoring hardening + val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i -> + tokenNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false) + } + if (!isSameAccount) return false + + // Check if change or index ≠ 0 + val change = tokenNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false) + val index = tokenNodes[INDEX_NODE_INDEX].getIndex(includeHardened = false) + + return change != 0L || index != 0L + } + private suspend fun updateTokenDynamicAddressesFlag( userWalletId: UserWalletId, network: Network, @@ -92,6 +139,11 @@ internal class DefaultDynamicAddressesRepository( } } + private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean { + // Check if WalletManager is already in XPUB mode (DA was previously enabled on this device) + return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null + } + private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? { return accounts .flatMap { it.tokens.orEmpty() } @@ -103,4 +155,11 @@ internal class DefaultDynamicAddressesRepository( derivationPath == network.derivationPath.value && contractAddress == null } + + private companion object { + const val DERIVATION_NODE_COUNT = 5 + const val ACCOUNT_NODE_COUNT = 3 + const val CHANGE_NODE_INDEX = 3 + const val INDEX_NODE_INDEX = 4 + } } \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt index c347bb1e54..3628a45990 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt @@ -2,6 +2,7 @@ package com.tangem.data.wallets.derivations import arrow.core.getOrElse import com.tangem.common.extensions.ByteArrayKey +import com.tangem.common.extensions.toMapKey import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.getSyncStrict @@ -76,6 +77,23 @@ internal class DefaultDerivationsRepository @Inject constructor( } } + override suspend fun getExistingDerivedKeys( + userWalletId: UserWalletId, + seedKey: ByteArrayKey, + ): ExtendedPublicKeysMap { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + return userWallet.getExistingDerivedKeys()[seedKey] ?: ExtendedPublicKeysMap(emptyMap()) + } + + private fun UserWallet.getExistingDerivedKeys(): Map { + return when (this) { + is UserWallet.Cold -> scanResponse.derivedKeys + is UserWallet.Hot -> wallets + ?.associate { it.publicKey.toMapKey() to ExtendedPublicKeysMap(it.derivedKeys) } + .orEmpty() + } + } + override suspend fun hasMissedDerivations( userWalletId: UserWalletId, networksWithDerivationPath: Map, diff --git a/domain/dynamic-addresses/build.gradle.kts b/domain/dynamic-addresses/build.gradle.kts index 6190ff052f..d366a99097 100644 --- a/domain/dynamic-addresses/build.gradle.kts +++ b/domain/dynamic-addresses/build.gradle.kts @@ -13,8 +13,12 @@ dependencies { api(projects.domain.dynamicAddresses.models) implementation(projects.domain.models) + implementation(projects.domain.walletManager) + implementation(projects.domain.wallets) + implementation(projects.libs.blockchainSdk) implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + implementation(tangemDeps.card.core) } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt new file mode 100644 index 0000000000..3e732ca6dc --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt @@ -0,0 +1,53 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchain.common.Blockchain + +/** + * List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode). + * Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred). + * + * Per ASMPT-005: DA is NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses. + * Only the default derivation style per blockchain is supported. + */ +object DynamicAddressesSupportedBlockchains { + + private const val BIP44_PURPOSE = 44L + private const val BIP84_PURPOSE = 84L + + private val supported = setOf( + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + Blockchain.BitcoinCash, + Blockchain.BitcoinCashTestnet, + Blockchain.Litecoin, + Blockchain.Dogecoin, + Blockchain.Dash, + Blockchain.Ravencoin, + Blockchain.RavencoinTestnet, + ) + + private val supportedNetworkIds = supported.map { it.id }.toSet() + + /** + * Allowed BIP purpose nodes per network ID. + * BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH). + */ + private val allowedPurposeByNetworkId: Map = buildMap { + put(Blockchain.Bitcoin.id, BIP84_PURPOSE) + put(Blockchain.BitcoinTestnet.id, BIP84_PURPOSE) + put(Blockchain.Litecoin.id, BIP84_PURPOSE) + put(Blockchain.BitcoinCash.id, BIP44_PURPOSE) + put(Blockchain.BitcoinCashTestnet.id, BIP44_PURPOSE) + put(Blockchain.Dogecoin.id, BIP44_PURPOSE) + put(Blockchain.Dash.id, BIP44_PURPOSE) + put(Blockchain.Ravencoin.id, BIP44_PURPOSE) + put(Blockchain.RavencoinTestnet.id, BIP44_PURPOSE) + } + + fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported + + fun isSupportedByNetworkId(networkId: String): Boolean = networkId in supportedNetworkIds + + /** Returns the allowed BIP purpose node for the given network, or null if not supported */ + fun getAllowedPurpose(networkId: String): Long? = allowedPurposeByNetworkId[networkId] +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt new file mode 100644 index 0000000000..10d9872222 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesError.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.dynamicaddresses + +sealed class EnableDynamicAddressesError { + + data object ConflictingCustomTokens : EnableDynamicAddressesError() + + data class ServiceError(val cause: Throwable) : EnableDynamicAddressesError() +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt index 8b888a54f4..7a4ceefba5 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/EnableDynamicAddressesUseCase.kt @@ -1,6 +1,8 @@ package com.tangem.domain.dynamicaddresses import arrow.core.Either +import arrow.core.left +import arrow.core.right import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -9,8 +11,19 @@ class EnableDynamicAddressesUseCase( private val dynamicAddressesRepository: DynamicAddressesRepository, ) { - suspend operator fun invoke(userWalletId: UserWalletId, network: Network, xpub: String): Either = - Either.catch { + suspend operator fun invoke( + userWalletId: UserWalletId, + network: Network, + xpub: String, + ): Either { + return try { + if (dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)) { + return EnableDynamicAddressesError.ConflictingCustomTokens.left() + } dynamicAddressesRepository.enable(userWalletId, network, xpub) + Unit.right() + } catch (e: Throwable) { + EnableDynamicAddressesError.ServiceError(e).left() } + } } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt new file mode 100644 index 0000000000..3a62af2f51 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt @@ -0,0 +1,37 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.derivations.DerivationsRepository + +/** + * Checks if the account-level XPUB key is already derived (no card scan needed). + */ +class IsXpubDerivedUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val derivationsRepository: DerivationsRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean { + val blockchain = network.toBlockchain() + if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false + if (!blockchain.isBip44DerivationStyleXPUB()) return false + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false + val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return false + if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return false + val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT)) + + val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey) + val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey) + return existingKeys[accountPath] != null + } + + private companion object { + const val ACCOUNT_PATH_DROP_COUNT = 2 + } +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt new file mode 100644 index 0000000000..ab998d8301 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubSupportedUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade + +/** + * Checks if XPUB generation is supported for the given wallet and network (hardware capability check). + */ +class IsXpubSupportedUseCase( + private val walletManagersFacade: WalletManagersFacade, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean { + val blockchain = network.toBlockchain() + if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false + return walletManager.wallet.publicKey.derivationType?.hdKey != null + } +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt index c30355fa48..4bcb627e16 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt @@ -19,4 +19,7 @@ interface DynamicAddressesRepository { suspend fun getLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String? suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean + + /** Returns true if there are custom tokens with change/index ≠ 0 that conflict with DA */ + suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt index 9eefdc6899..a3ee510fde 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt @@ -29,6 +29,9 @@ interface DerivationsRepository { derivations: Map>, ): Map + /** Returns already derived extended public keys for the given [seedKey] */ + suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap + /** Check if user [userWalletId] has missed derivations using map of [Network.ID] with extraDerivationPath */ suspend fun hasMissedDerivations( userWalletId: UserWalletId, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt index e548eef8b7..100979e021 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetExtendedPublicKeyForCurrencyUseCase.kt @@ -1,7 +1,6 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either -import arrow.core.right import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.extensions.ByteArrayKey @@ -38,23 +37,37 @@ class GetExtendedPublicKeyForCurrencyUseCase( error("No derivation found") } + val seedKey = walletManager.wallet.publicKey.seedKey + val existingKeys = derivationsRepository.getExistingDerivedKeys( + userWalletId = userWalletId, + seedKey = ByteArrayKey(seedKey), + ) + var childKey = makeChildKey( isBip44DerivationStyleXPUB = blockchain.isBip44DerivationStyleXPUB(), extendedPublicKey = hdKey.extendedPublicKey, derivationPath = hdKey.path, ) + // Fill from already derived keys if available + if (childKey.extendedPublicKey == null) { + existingKeys[childKey.derivationPath]?.let { + childKey = childKey.copy(extendedPublicKey = it) + } + } + + val parentPath = childKey.derivationPath.dropLastNodes(1) var parentKey = Key( - derivationPath = childKey.derivationPath.dropLastNodes(1), - extendedPublicKey = null, + derivationPath = parentPath, + extendedPublicKey = existingKeys[parentPath], ) val pendingDerivations = getPendingDerivations(childKey, parentKey) - val derivedKeys = deriveKeys( - userWalletId = userWalletId, - seedKey = walletManager.wallet.publicKey.seedKey, - paths = pendingDerivations, - ) + val derivedKeys = if (pendingDerivations.isNotEmpty()) { + deriveKeys(userWalletId = userWalletId, seedKey = seedKey, paths = pendingDerivations) + } else { + ExtendedPublicKeysMap(emptyMap()) + } if (childKey.extendedPublicKey == null) { childKey = childKey.copy( @@ -72,22 +85,6 @@ class GetExtendedPublicKeyForCurrencyUseCase( } } - /** - * @return true if xpub generation is supported, false otherwise - */ - suspend fun isSupported(userWalletId: UserWalletId, network: Network): Either = Either.catch { - val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) - ?: error("Wallet not found for user wallet $userWalletId and network ${network.id}") - - val blockchain = network.toBlockchain() - val isSecp256k1Blockchain = Blockchain.secp256k1Blockchains(network.isTestnet).contains(blockchain) - val isHdKey = walletManager.wallet.publicKey.derivationType?.hdKey - - val isSupported = isSecp256k1Blockchain && isHdKey != null - - return isSupported.right() - } - private suspend fun deriveKeys( userWalletId: UserWalletId, seedKey: ByteArray, diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 51a9203ba8..36bed031e2 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -68,6 +68,8 @@ dependencies { implementation(projects.domain.balanceHiding.models) implementation(projects.domain.card) implementation(projects.domain.demo) + implementation(projects.domain.dynamicAddresses) + implementation(projects.domain.dynamicAddresses.models) implementation(projects.domain.markets.models) implementation(projects.domain.models) implementation(projects.domain.notifications.models) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index a8f037db6d..035e4511bf 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -24,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetails import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -148,6 +149,10 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( cloreMigrationModel = model.cloreMigrationModel, onDismiss = model.bottomSheetNavigation::dismiss, ) + is TokenDetailsBottomSheetConfig.DynamicAddresses -> DynamicAddressesBottomSheetComponent( + dynamicAddressesDelegate = model.dynamicAddressesDelegate, + onDismiss = model.bottomSheetNavigation::dismiss, + ) } @AssistedFactory diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt new file mode 100644 index 0000000000..e9fe02cce1 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt @@ -0,0 +1,125 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.model + +import com.tangem.common.core.TangemSdkError +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.res.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@Suppress("LongParameterList") +internal class DynamicAddressesDelegate( + private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase, + private val isXpubDerivedUseCase: IsXpubDerivedUseCase, + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase, + private val uiMessageSender: UiMessageSender, + private val userWalletId: UserWalletId, + private val network: Network, + private val coroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatcherProvider, + private val showBottomSheet: () -> Unit, + private val dismissBottomSheet: () -> Unit, + private val onDynamicAddressesEnabled: () -> Unit, +) { + + private val _bottomSheetConfig = MutableStateFlow( + DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = false, + onEnableClick = {}, + ), + ) + val bottomSheetConfig: StateFlow = _bottomSheetConfig.asStateFlow() + + fun onDynamicAddressesClick() { + coroutineScope.launch(dispatchers.main) { + val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) + if (hasConflicts) { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable( + onGotItClick = dismissBottomSheet, + ) + showBottomSheet() + return@launch + } + + val isCardScanRequired = !isXpubAlreadyDerived() + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = isCardScanRequired, + onEnableClick = ::onEnableClick, + ) + showBottomSheet() + } + } + + private fun onEnableClick() { + coroutineScope.launch(dispatchers.main) { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = false, + isLoading = true, + onEnableClick = {}, + ) + + val xpub = getExtendedPublicKeyUseCase(userWalletId, network).fold( + ifLeft = { error -> + if (isUserCancellation(error)) { + dismissBottomSheet() + } else { + TangemLogger.e("Failed to get XPUB: ${error.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onGotItClick = dismissBottomSheet, + ) + } + return@launch + }, + ifRight = { it }, + ) + + enableDynamicAddressesUseCase(userWalletId, network, xpub).fold( + ifLeft = { error -> + when (error) { + is EnableDynamicAddressesError.ConflictingCustomTokens -> { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable( + onGotItClick = dismissBottomSheet, + ) + } + is EnableDynamicAddressesError.ServiceError -> { + TangemLogger.e("Failed to enable DA: ${error.cause.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onGotItClick = dismissBottomSheet, + ) + } + } + }, + ifRight = { + dismissBottomSheet() + onDynamicAddressesEnabled() + uiMessageSender.send( + SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_enabled_toast_title)), + ) + }, + ) + } + } + + private suspend fun isXpubAlreadyDerived(): Boolean { + return isXpubDerivedUseCase(userWalletId, network) + } + + private fun isUserCancellation(error: Throwable): Boolean { + return error is TangemSdkError.UserCancelled || error.cause is TangemSdkError.UserCancelled + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index af6e80b86e..81e1b3a3d0 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -48,6 +48,8 @@ interface TokenDetailsClickIntents { fun onGenerateExtendedKey() + fun onDynamicAddressesClick() + fun onCopyAddress(): TextReference? fun onAssociateClick() @@ -125,6 +127,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents { override fun onGenerateExtendedKey() { /* no op */ } + override fun onDynamicAddressesClick() { /* no op */ } + override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 4cd0017182..46d9a4eb87 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -2,13 +2,19 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import androidx.compose.runtime.Stable import arrow.core.getOrElse -import arrow.core.merge import arrow.core.right import com.tangem.utils.logging.TangemLogger import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.address.AddressType +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains +import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase +import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase +import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.receive.AddressModel @@ -16,9 +22,7 @@ import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.api.AnalyticsExceptionHandler import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.ExceptionAnalyticsEvent import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.di.ModelScoped @@ -53,6 +57,7 @@ import com.tangem.domain.models.TokenReceiveNotification 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 @@ -154,7 +159,6 @@ internal class TokenDetailsModel @Inject constructor( private val appRouter: AppRouter, private val router: InnerTokenDetailsRouter, private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener, - private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val receiveAddressesFactory: ReceiveAddressesFactory, private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase, private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase, @@ -163,6 +167,11 @@ internal class TokenDetailsModel @Inject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase, private val signCloreMessageUseCase: SignCloreMessageUseCase, + private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase, + private val isXpubSupportedUseCase: IsXpubSupportedUseCase, + private val isXpubDerivedUseCase: IsXpubDerivedUseCase, + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ) : Model(), TokenDetailsClickIntents, ExpressTransactionsClickIntents, @@ -226,6 +235,27 @@ internal class TokenDetailsModel @Inject constructor( } // endregion + // region Dynamic Addresses + val dynamicAddressesDelegate by lazy(mode = LazyThreadSafetyMode.NONE) { + DynamicAddressesDelegate( + enableDynamicAddressesUseCase = enableDynamicAddressesUseCase, + isXpubDerivedUseCase = isXpubDerivedUseCase, + dynamicAddressesRepository = dynamicAddressesRepository, + getExtendedPublicKeyUseCase = getExtendedPublicKeyForCurrencyUseCase, + uiMessageSender = uiMessageSender, + userWalletId = userWalletId, + network = cryptoCurrency.network, + coroutineScope = modelScope, + dispatchers = dispatchers, + showBottomSheet = { + bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.DynamicAddresses) + }, + dismissBottomSheet = bottomSheetNavigation::dismiss, + onDynamicAddressesEnabled = ::onDynamicAddressesEnabled, + ) + } + // endregion Dynamic Addresses + private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) { tokenDetailsExpressStatusFactory.create( clickIntents = this, @@ -485,39 +515,45 @@ internal class TokenDetailsModel @Inject constructor( ).getOrElse { false } val isSupported = isXPUBSupported() + val isDynamicAddressesAvailable = isSupported && isDynamicAddressesAvailable() internalUiState.value = stateFactory.getStateWithUpdatedMenu( userWallet = userWallet, hasDerivations = hasDerivations, isSupported = isSupported, + isDynamicAddressesAvailable = isDynamicAddressesAvailable, ) } } + private fun isDynamicAddressesAvailable(): Boolean { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false + if (cryptoCurrency !is CryptoCurrency.Coin) return false + + val networkId = cryptoCurrency.network.rawId + if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(networkId)) return false + + return isDefaultBaseDerivation(cryptoCurrency.network.derivationPath, networkId) + } + + private fun isDefaultBaseDerivation(derivationPath: Network.DerivationPath, networkId: String): Boolean { + val pathValue = derivationPath.value ?: return false + val nodes = runCatching { DerivationPath(pathValue).nodes }.getOrNull() ?: return false + if (nodes.size < BASE_DERIVATION_NODE_COUNT) return false + + val purposeNode = nodes.first() + val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false + if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false + + val changeNode = nodes[nodes.size - 2] + val indexNode = nodes.last() + + return changeNode.getIndex(includeHardened = false) == 0L && + indexNode.getIndex(includeHardened = false) == 0L + } + private suspend fun isXPUBSupported(): Boolean { - return getExtendedPublicKeyForCurrencyUseCase.isSupported( - userWalletId = userWalletId, - network = cryptoCurrency.network, - ) - .mapLeft { throwable -> - analyticsExceptionHandler.sendException( - event = ExceptionAnalyticsEvent( - exception = throwable, - params = mapOf( - "blockchainId" to cryptoCurrency.network.id.rawId.value, - "networkId" to cryptoCurrency.network.backendId, - ), - ), - ) - - TangemLogger.e( - "Unable to get wallet manager for user wallet $userWalletId and network ${cryptoCurrency.network}", - throwable, - ) - - false - } - .merge() + return isXpubSupportedUseCase(userWalletId = userWalletId, network = cryptoCurrency.network) } private fun createSelectedAppCurrencyFlow(): StateFlow { @@ -657,6 +693,15 @@ internal class TokenDetailsModel @Inject constructor( openStaking() } + override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick() + + private fun onDynamicAddressesEnabled() { + updateTopBarMenu() + modelScope.launch(dispatchers.main) { + cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency) + } + } + override fun onGenerateExtendedKey() { modelScope.launch(dispatchers.main) { val extendedKey = getExtendedPublicKeyForCurrencyUseCase( @@ -1382,5 +1427,6 @@ internal class TokenDetailsModel @Inject constructor( private companion object { const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L + const val BASE_DERIVATION_NODE_COUNT = 5 } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt index 8ba904843b..071fa53e51 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/route/TokenDetailsBottomSheetConfig.kt @@ -27,4 +27,7 @@ sealed class TokenDetailsBottomSheetConfig : Route { @Serializable data object CloreMigration : TokenDetailsBottomSheetConfig() + + @Serializable + data object DynamicAddresses : TokenDetailsBottomSheetConfig() } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt index 3739c109ba..bd9da5b438 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsStateFactory.kt @@ -167,12 +167,18 @@ internal class TokenDetailsStateFactory( userWallet: UserWallet, hasDerivations: Boolean, isSupported: Boolean, + isDynamicAddressesAvailable: Boolean = false, ): TokenDetailsState { return with(currentStateProvider()) { copy( topAppBarConfig = topAppBarConfig.copy( tokenDetailsAppBarMenuConfig = topAppBarConfig.tokenDetailsAppBarMenuConfig - ?.updateMenu(userWallet, hasDerivations, isSupported), + ?.updateMenu( + userWallet = userWallet, + hasDerivations = hasDerivations, + isSupported = isSupported, + isDynamicAddressesAvailable = isDynamicAddressesAvailable, + ), ), ) } @@ -206,6 +212,7 @@ internal class TokenDetailsStateFactory( userWallet: UserWallet, hasDerivations: Boolean, isSupported: Boolean, + isDynamicAddressesAvailable: Boolean, ): TokenDetailsAppBarMenuConfig? { if (userWallet is UserWallet.Cold && userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() @@ -215,6 +222,13 @@ internal class TokenDetailsStateFactory( return copy( items = buildList { + if (isDynamicAddressesAvailable) { + TangemDropdownMenuItem( + title = resourceReference(R.string.dynamic_addresses), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = tokenDetailsClickIntents::onDynamicAddressesClick, + ).let(::add) + } if (isSupported && hasDerivations) { TangemDropdownMenuItem( title = resourceReference(R.string.token_details_generate_xpub), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt new file mode 100644 index 0000000000..6b476ee779 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/bottomsheet/DynamicAddressesBottomSheetComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.feature.tokendetails.presentation.tokendetails.model.DynamicAddressesDelegate +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheet + +internal class DynamicAddressesBottomSheetComponent( + private val dynamicAddressesDelegate: DynamicAddressesDelegate, + private val onDismiss: () -> Unit, +) : ComposableBottomSheetComponent { + + override fun dismiss() { + onDismiss() + } + + @Composable + override fun BottomSheet() { + val content by dynamicAddressesDelegate.bottomSheetConfig.collectAsStateWithLifecycle() + + val config = remember(content) { + TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = content, + ) + } + DynamicAddressesBottomSheet(config = config) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt new file mode 100644 index 0000000000..a12557f768 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.R as CoreR + +@Composable +internal fun DynamicAddressesBottomSheet(config: TangemBottomSheetConfig) { + TangemModalBottomSheet( + config = config, + title = { + TangemModalBottomSheetTitle( + endIconRes = CoreR.drawable.ic_close_24, + onEndClick = config.onDismissRequest, + ) + }, + ) { content -> + when (content) { + is DynamicAddressesBottomSheetConfig.Enable -> DynamicAddressesEnableContent(content = content) + is DynamicAddressesBottomSheetConfig.Unavailable -> DynamicAddressesUnavailableContent(content = content) + is DynamicAddressesBottomSheetConfig.ServiceUnavailable -> DynamicAddressesServiceUnavailableContent( + content = content, + ) + } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt new file mode 100644 index 0000000000..a228fb29b2 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt @@ -0,0 +1,22 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent + +@Immutable +internal sealed class DynamicAddressesBottomSheetConfig : TangemBottomSheetConfigContent { + + data class Enable( + val isCardScanRequired: Boolean, + val isLoading: Boolean = false, + val onEnableClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + data class Unavailable( + val onGotItClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + data class ServiceUnavailable( + val onGotItClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt new file mode 100644 index 0000000000..fb1eaa7605 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt @@ -0,0 +1,179 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.res.R +import com.tangem.core.ui.R as CoreR + +@Composable +internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetConfig.Enable) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_top), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size44), + tint = TangemTheme.colors.icon.accent, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses_enter_subtitle), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + FeatureItem( + iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_flash_24, + title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_title), + description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_description), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + + FeatureItem( + iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_check_24, + title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_title), + description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_description), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButton( + text = stringResourceSafe(id = R.string.dynamic_addresses_enter_main_button_title), + onClick = content.onEnableClick, + modifier = Modifier.fillMaxWidth(), + showProgress = content.isLoading, + enabled = !content.isLoading, + // TODO add card icon when isCardScanRequired + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +internal fun DynamicAddressesUnavailableContent(content: DynamicAddressesBottomSheetConfig.Unavailable) { + ErrorContent( + titleRes = R.string.dynamic_addresses_error_has_custom_token_title, + descriptionRes = R.string.dynamic_addresses_error_has_custom_token_description, + buttonTextRes = R.string.common_got_it, + onButtonClick = content.onGotItClick, + ) +} + +@Composable +internal fun DynamicAddressesServiceUnavailableContent(content: DynamicAddressesBottomSheetConfig.ServiceUnavailable) { + ErrorContent( + titleRes = R.string.dynamic_addresses_error_service_unavailable_title, + descriptionRes = R.string.dynamic_addresses_error_service_unavailable_description, + buttonTextRes = R.string.common_got_it, + onButtonClick = content.onGotItClick, + ) +} + +@Composable +private fun ErrorContent(titleRes: Int, descriptionRes: Int, buttonTextRes: Int, onButtonClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size44), + tint = TangemTheme.colors.icon.warning, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Text( + text = stringResourceSafe(id = titleRes), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + + Text( + text = stringResourceSafe(id = descriptionRes), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButton( + text = stringResourceSafe(id = buttonTextRes), + onClick = onButtonClick, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +private fun FeatureItem(iconRes: Int, title: String, description: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size24), + tint = TangemTheme.colors.icon.accent, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing4)) + Text( + text = description, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } + } +} \ No newline at end of file From 9c8bfd4139df6a555ad6b9e23ff009d1c399490c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Apr 2026 15:25:25 +0200 Subject: [PATCH 004/206] Updated on 2026-08-14 --- .../components/fields/entity/SearchBarUM.kt | 1 + .../ui/ds/field/search/TangemSearchField.kt | 9 +- .../ui/ds/opportunities/OpportunitiesBG.kt | 4 +- .../domain/search/model/SearchResult.kt | 2 +- .../search/model/UserAssetSearchEntry.kt | 2 + .../search/model/UserAssetSearchItem.kt | 13 ++ .../search/usecase/GetSearchResultsUseCase.kt | 46 ++++++- .../feed/components/FeedEntryChildFactory.kt | 7 + .../search/DefaultSearchComponent.kt | 4 + .../features/feed/model/search/SearchModel.kt | 87 +++++++----- .../converter/UserAssetSearchItemConverter.kt | 92 +++++++++++++ .../features/feed/ui/search/SearchContent.kt | 125 +++++++++++++++--- .../ui/search/preview/SearchContentPreview.kt | 40 +++--- .../feed/ui/search/state/SearchCallbacks.kt | 1 + .../features/feed/ui/search/state/SearchUM.kt | 53 ++++++-- 15 files changed, 394 insertions(+), 92 deletions(-) create mode 100644 domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt index 158c768fe8..6050d96cb3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/entity/SearchBarUM.kt @@ -9,4 +9,5 @@ data class SearchBarUM( val isActive: Boolean, val onActiveChange: (Boolean) -> Unit, val onClearClick: () -> Unit = {}, + val onCancelClick: (() -> Unit)? = null, ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt index 4e2e9ebb42..905ef8c5fb 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/field/search/TangemSearchField.kt @@ -289,8 +289,13 @@ private fun CancelButton( state.onQueryChange("") } keyboardController?.hide() - state.onClearClick() - focusManager.clearFocus() + val onCancel = state.onCancelClick + if (onCancel != null) { + onCancel() + } else { + state.onClearClick() + focusManager.clearFocus() + } }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt index 79f093e351..ac427c9961 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt @@ -193,7 +193,7 @@ private fun BoxScope.UrlBackground(imageUrl: String?, blurRadius: Dp) { AsyncImage( model = imageRequest, contentDescription = null, - contentScale = ContentScale.Crop, + contentScale = ContentScale.FillBounds, modifier = Modifier .matchParentSize() .scale(SCALE_FACTOR) @@ -207,7 +207,7 @@ private fun BoxScope.ResBackground(res: Int, blurRadius: Dp) { Image( painter = painterResource(res), contentDescription = null, - contentScale = ContentScale.Crop, + contentScale = ContentScale.FillBounds, modifier = Modifier .matchParentSize() .scale(SCALE_FACTOR) diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt b/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt index 98a25e4996..40fc76fed4 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/model/SearchResult.kt @@ -3,5 +3,5 @@ package com.tangem.domain.search.model data class SearchResult( val textHints: List, val recentTokens: List, - val userAssets: List, + val userAssets: List, ) \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt index fd64a23e24..c6a3d45854 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt @@ -2,6 +2,7 @@ package com.tangem.domain.search.model import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId @@ -10,5 +11,6 @@ data class UserAssetSearchEntry( val userWalletName: String, val accountId: AccountId, val accountName: AccountName, + val accountIcon: CryptoPortfolioIcon, val currencyStatus: CryptoCurrencyStatus, ) \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt new file mode 100644 index 0000000000..7a63289486 --- /dev/null +++ b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.search.model + +sealed interface UserAssetSearchItem { + + data class Single(val entry: UserAssetSearchEntry) : UserAssetSearchItem + + data class Grouped( + val tokenName: String, + val tokenSymbol: String, + val tokenIconUrl: String?, + val entries: List, + ) : UserAssetSearchItem +} \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt index b6167208a2..0b6b37be48 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt @@ -9,10 +9,12 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.search.model.SearchResult import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.search.model.UserAssetSearchItem import com.tangem.domain.search.repository.SearchRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map +import java.math.BigDecimal /** * Primary search use case that produces [SearchResult] based on the current query. @@ -70,9 +72,12 @@ class GetSearchResultsUseCase( if (unlockedWallets.isEmpty()) return@combine emptyList() - statusLists + val entries = statusLists .filter { it.userWalletId in unlockedWallets } .flatMap { statusList -> extractMatchingAssets(statusList, unlockedWallets, lowerQuery) } + + val shouldGroup = needsGrouping(unlockedWallets.values, statusLists) + groupAndSort(entries, shouldGroup) }.map { userAssets -> SearchResult( textHints = emptyList(), @@ -82,6 +87,44 @@ class GetSearchResultsUseCase( } } + private fun needsGrouping(unlockedWallets: Collection, statusLists: List): Boolean { + if (unlockedWallets.size > 1) return true + + val totalAccounts = statusLists + .filter { sl -> unlockedWallets.any { it.walletId == sl.userWalletId } } + .sumOf { it.accountStatuses.filterCryptoPortfolio().size } + + return totalAccounts > 1 + } + + private fun groupAndSort(entries: List, shouldGroup: Boolean): List { + if (!shouldGroup) { + return entries + .map { UserAssetSearchItem.Single(it) } + .sortedByDescending { it.entry.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + } + + val grouped = entries.groupBy { entry -> + val rawId = entry.currencyStatus.currency.id.rawCurrencyId + rawId?.value ?: "${entry.currencyStatus.currency.name}|${entry.currencyStatus.currency.symbol}" + } + + return grouped.map { (_, groupEntries) -> + val assetInfo = groupEntries.first() + UserAssetSearchItem.Grouped( + tokenName = assetInfo.currencyStatus.currency.name, + tokenSymbol = assetInfo.currencyStatus.currency.symbol, + tokenIconUrl = assetInfo.currencyStatus.currency.iconUrl, + entries = groupEntries, + ) + }.sortedByDescending { item -> + when (item) { + is UserAssetSearchItem.Grouped -> + item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + } + } + } + private fun extractMatchingAssets( statusList: AccountStatusList, wallets: Map, @@ -103,6 +146,7 @@ class GetSearchResultsUseCase( userWalletName = wallet.name, accountId = accountStatus.accountId, accountName = accountStatus.account.accountName, + accountIcon = accountStatus.account.icon, currencyStatus = currencyStatus, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index a51d8689f7..fb93121de6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -148,6 +148,13 @@ internal class FeedEntryChildFactory @Inject constructor( appComponentContext = appComponentContext, params = DefaultSearchComponent.Params( onBackClick = onBackClicked, + onMarketTokenClick = { token, currency -> + feedEntryClickIntents.onMarketItemClick( + token = token, + appCurrency = currency, + source = AnalyticsParam.ScreensSources.Market.value, + ) + }, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt index b8a8ac1119..677f40ed5a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt @@ -15,6 +15,8 @@ import com.tangem.core.ui.ds.field.search.TangemFieldShape import com.tangem.core.ui.ds.field.search.TangemSearchField import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.model.search.SearchModel import com.tangem.features.feed.ui.search.SearchContent import com.tangem.features.feed.ui.search.state.SearchCallbacks @@ -74,6 +76,7 @@ internal class DefaultSearchComponent( onClearHintsClick = model::clearSearchHistory, onTextHintClick = model::onTextHintClick, onResultMarketTokenClick = model::onResultMarketTokenClick, + onHistoryTokenClick = model::onHistoryTokenClick, ) } SearchContent( @@ -86,5 +89,6 @@ internal class DefaultSearchComponent( data class Params( val onBackClick: () -> Unit, + val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt index 2273f437bb..f43e8c5766 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt @@ -1,19 +1,21 @@ package com.tangem.features.feed.model.search import arrow.core.getOrElse +import com.tangem.common.ui.charts.state.MarketChartData +import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter +import com.tangem.common.ui.charts.state.sorted import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.common.ui.charts.state.MarketChartData -import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter -import com.tangem.common.ui.charts.state.sorted +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase import com.tangem.domain.markets.GetTokenPriceChartUseCase import com.tangem.domain.markets.PriceChangeInterval -import com.tangem.domain.models.account.AccountName +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase import com.tangem.domain.search.usecase.GetSearchResultsUseCase import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase @@ -26,6 +28,7 @@ import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecent import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency +import com.tangem.features.feed.model.search.converter.UserAssetSearchItemConverter import com.tangem.features.feed.model.search.state.SearchStateController import com.tangem.features.feed.model.search.state.transformers.* import com.tangem.features.feed.ui.search.state.* @@ -35,13 +38,8 @@ import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L @@ -54,6 +52,7 @@ internal class SearchModel @Inject constructor( paramsContainer: ParamsContainer, getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getSearchResultsUseCase: GetSearchResultsUseCase, private val saveSearchQueryUseCase: SaveSearchQueryUseCase, private val saveRecentSearchTokenUseCase: SaveRecentSearchTokenUseCase, @@ -77,6 +76,13 @@ internal class SearchModel @Inject constructor( initialValue = AppCurrency.Default, ) + private val isBalanceHidden = getBalanceHidingSettingsUseCase.isBalanceHidden() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = false, + ) + private val marketsListItemToRecentSearchTokenConverter by lazy { MarketsListItemUMToRecentSearchTokenConverter() } @@ -143,17 +149,38 @@ internal class SearchModel @Inject constructor( ) saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input)) saveSearchQueryUseCase(stateController.value.searchBar.query) + withContext(dispatchers.mainImmediate) { + searchMarketsListManager.getTokenById(item.id)?.let { found -> + params.onMarketTokenClick(found.toSerializableParam(), appCurrency) + } + } } } + fun onHistoryTokenClick(item: MarketsListItemUM) { + val tokenMarketParams = TokenMarketParams( + id = item.id, + name = item.name, + symbol = item.currencySymbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = item.price.fiatPrice, + h24Percent = null, + weekPercent = null, + monthPercent = null, + ), + imageUrl = item.iconUrl, + ) + params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value) + } + private fun initCallbacks() { stateController.update(object : SearchUMTransformer { override fun transform(prevState: SearchUM): SearchUM { return prevState.copy( searchBar = prevState.searchBar.copy( onQueryChange = ::onQueryChange, - onActiveChange = ::onActiveChange, onClearClick = ::onClearClick, + onCancelClick = params.onBackClick, ), ) } @@ -164,10 +191,6 @@ internal class SearchModel @Inject constructor( stateController.update(UpdateSearchBarQueryTransformer(query)) } - private fun onActiveChange(isActive: Boolean) { - if (!isActive) params.onBackClick() - } - private fun onClearClick() { stateController.update(UpdateSearchBarQueryTransformer("")) } @@ -200,20 +223,19 @@ internal class SearchModel @Inject constructor( private fun subscribeToSearchResults(query: String) { modelScope.launch { - getSearchResultsUseCase(query = query).collectLatest { searchResult -> - val userAssets = searchResult.userAssets.map { entry -> - UserAssetItemUM( - id = "${entry.userWalletId.stringValue}_${entry.accountId.value}" + - "_${entry.currencyStatus.currency.id.value}", - tokenIconUrl = entry.currencyStatus.currency.iconUrl, - tokenName = entry.currencyStatus.currency.name, - tokenSymbol = entry.currencyStatus.currency.symbol, - accountName = entry.accountName.toDisplayString(), - onClick = { - // TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task. - }, - ) - }.toImmutableList() + combine( + getSearchResultsUseCase(query = query), + currentAppCurrency, + isBalanceHidden, + ) { searchResult, appCurrency, balanceHidden -> + val converter = UserAssetSearchItemConverter( + appCurrency = appCurrency, + isBalanceHidden = balanceHidden, + ) + searchResult.userAssets + .map(converter::convert) + .toImmutableList() + }.collectLatest { userAssets -> stateController.update(UpdateUserAssetsTransformer(userAssets)) } }.saveIn(searchResultsJob) @@ -334,13 +356,6 @@ internal class SearchModel @Inject constructor( } } - private fun AccountName.toDisplayString(): String { - return when (this) { - is AccountName.DefaultMain -> "Main" // TODO [REDACTED_TASK_KEY] localize - is AccountName.Custom -> value - } - } - private fun CoroutineScope.loadQuotesWithTimer(timeMillis: Long) { launch { while (true) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt new file mode 100644 index 0000000000..4e742afdde --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt @@ -0,0 +1,92 @@ +package com.tangem.features.feed.model.search.converter + +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.search.model.UserAssetSearchItem +import com.tangem.features.feed.ui.search.state.UserAssetItemUM +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +internal class UserAssetSearchItemConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, +) : Converter { + + override fun convert(value: UserAssetSearchItem): UserAssetItemUM { + return when (value) { + is UserAssetSearchItem.Single -> convertSingle(value.entry) + is UserAssetSearchItem.Grouped -> convertGrouped(value) + } + } + + private fun convertSingle(entry: UserAssetSearchEntry): UserAssetItemUM.Single { + val currency = entry.currencyStatus.currency + val value = entry.currencyStatus.value + + return UserAssetItemUM.Single( + id = "${entry.userWalletId.stringValue}_${entry.accountId.value}_${currency.id.value}", + icon = TangemIconUM.Currency( + currencyIconState = CryptoCurrencyToIconStateConverter().convert(entry.currencyStatus), + ), + tokenName = currency.name, + tokenSymbol = currency.symbol, + fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) }, + cryptoBalance = formatCryptoAmount(value.amount, currency.symbol, currency.decimals), + fiatBalance = formatFiatAmount(value.fiatAmount), + isBalanceHidden = isBalanceHidden, + onClick = {}, + ) + } + + private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped { + val totalFiat = item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + val totalCrypto = item.entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO } + + val firstCurrency = item.entries.first().currencyStatus.currency + val children = item.entries.map { entry -> + UserAssetItemUM.GroupedChild( + walletName = entry.userWalletName, + accountName = entry.accountName.toUM(), + accountIcon = entry.accountIcon.value, + accountColor = entry.accountIcon.color, + cryptoBalance = formatCryptoAmount( + entry.currencyStatus.value.amount, + entry.currencyStatus.currency.symbol, + entry.currencyStatus.currency.decimals, + ), + fiatBalance = formatFiatAmount(entry.currencyStatus.value.fiatAmount), + ) + }.toImmutableList() + + return UserAssetItemUM.Grouped( + id = "grouped_${item.tokenName}_${item.tokenSymbol}", + icon = TangemIconUM.Currency( + currencyIconState = CryptoCurrencyToIconStateConverter().convert(item.entries.first().currencyStatus), + ), + tokenName = item.tokenName, + tokenSymbol = item.tokenSymbol, + tokensCount = item.entries.size, + totalCryptoBalance = formatCryptoAmount(totalCrypto, firstCurrency.symbol, firstCurrency.decimals), + totalFiatBalance = formatFiatAmount(totalFiat), + isBalanceHidden = isBalanceHidden, + children = children, + onClick = {}, + ) + } + + private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String { + return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN + } + + private fun formatFiatAmount(fiatAmount: BigDecimal?): String { + return fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } ?: StringsSigns.DASH_SIGN + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt index 287b580b15..6d7118f812 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt @@ -3,20 +3,16 @@ package com.tangem.features.feed.ui.search import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity @@ -32,7 +28,6 @@ import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.image.TangemIcon -import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe @@ -54,6 +49,15 @@ internal fun SearchContent( val lazyListState = rememberLazyListState() val background = LocalMainBottomSheetColor.current.value + val contentStructureKey = when (content) { + is SearchContentUM.InitialEmpty -> "empty" + is SearchContentUM.History -> "history" + is SearchContentUM.Results -> "results_${content.userAssets.isNotEmpty()}" + } + LaunchedEffect(contentStructureKey) { + lazyListState.scrollToItem(0) + } + LazyColumn( state = lazyListState, modifier = modifier @@ -72,6 +76,7 @@ internal fun SearchContent( history = content, onClearAllClick = searchCallbacks.onClearHintsClick, onHintClick = searchCallbacks.onTextHintClick, + onHistoryTokenClick = searchCallbacks.onHistoryTokenClick, ) is SearchContentUM.Results -> searchResultsItems( results = content, @@ -98,6 +103,7 @@ private fun LazyListScope.searchHistoryItems( history: SearchContentUM.History, onClearAllClick: (() -> Unit), onHintClick: (String) -> Unit, + onHistoryTokenClick: (MarketsListItemUM) -> Unit, ) { if (!history.textHints.isEmpty() || !history.recentTokens.isEmpty()) { item(key = "recents") { @@ -107,15 +113,20 @@ private fun LazyListScope.searchHistoryItems( ) } } - items( + itemsIndexed( items = history.textHints, - key = { "hint_${it.text}" }, - ) { hint -> + key = { _, item -> "hint_${item.text}" }, + ) { index, hint -> TextHintItem(hint = hint, onHintClick = { onHintClick(hint.text) }) - HorizontalDivider( - modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2), - color = TangemTheme.colors2.border.neutral.primary, - ) + if (index < history.textHints.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2), + color = TangemTheme.colors2.border.neutral.primary, + ) + } + } + item { + SpacerH(TangemTheme.dimens2.x2) } items( items = history.recentTokens, @@ -129,7 +140,7 @@ private fun LazyListScope.searchHistoryItems( shape = RoundedCornerShape(TangemTheme.dimens2.x5), ), model = token, - onClick = {}, // TODO in [REDACTED_TASK_KEY] + onClick = { onHistoryTokenClick(token) }, ) } } @@ -264,9 +275,16 @@ private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) { } } -// TODO in [REDACTED_TASK_KEY] while just a stub item. Will be handled in next task. @Composable private fun UserAssetItem(asset: UserAssetItemUM) { + when (asset) { + is UserAssetItemUM.Single -> SingleUserAssetItem(asset) + is UserAssetItemUM.Grouped -> GroupedUserAssetItem(asset) + } +} + +@Composable +private fun SingleUserAssetItem(asset: UserAssetItemUM.Single) { Row( modifier = Modifier .fillMaxWidth() @@ -276,10 +294,8 @@ private fun UserAssetItem(asset: UserAssetItemUM) { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TangemIcon( - tangemIconUM = TangemIconUM.Url(asset.tokenIconUrl, fallbackRes = R.drawable.ic_custom_token_44), - modifier = Modifier - .size(40.dp) - .clip(CircleShape), + modifier = Modifier.size(40.dp), + tangemIconUM = asset.icon, ) Column(modifier = Modifier.weight(1f)) { Text( @@ -289,12 +305,79 @@ private fun UserAssetItem(asset: UserAssetItemUM) { maxLines = 1, ) Text( - text = "${asset.tokenSymbol} · ${asset.accountName}", + text = asset.tokenSymbol, style = TangemTheme.typography2.captionRegular13, color = TangemTheme.colors2.text.neutral.tertiary, maxLines = 1, ) } + if (!asset.isBalanceHidden) { + Column(horizontalAlignment = Alignment.End) { + Text( + text = asset.fiatBalance, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = asset.cryptoBalance, + style = TangemTheme.typography2.captionRegular13, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + ) + } + } + } +} + +// TODO [REDACTED_JIRA] update ui item to Portfolio block item +@Composable +private fun GroupedUserAssetItem(asset: UserAssetItemUM.Grouped) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = asset.onClick) + .padding(horizontal = 12.dp, vertical = 14.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TangemIcon( + modifier = Modifier.size(40.dp), + tangemIconUM = asset.icon, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = asset.tokenName, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = "${asset.tokenSymbol} · ${asset.tokensCount}", + style = TangemTheme.typography2.captionRegular13, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + ) + } + if (!asset.isBalanceHidden) { + Column(horizontalAlignment = Alignment.End) { + Text( + text = asset.totalFiatBalance, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = asset.totalCryptoBalance, + style = TangemTheme.typography2.captionRegular13, + color = TangemTheme.colors2.text.neutral.tertiary, + maxLines = 1, + ) + } + } + } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt index bfb95e3819..e5cd68f79c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt @@ -2,11 +2,7 @@ package com.tangem.features.feed.ui.search.preview import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -15,7 +11,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -206,18 +204,22 @@ internal object SearchContentPreviewFixtures { updateTimestamp = updateTimestamp, ) - private fun userAsset( - id: String, - name: String, - symbol: String, - accountName: String, - iconUrl: String? = null, - ): UserAssetItemUM = UserAssetItemUM( + private fun userAsset(id: String, name: String, symbol: String): UserAssetItemUM = UserAssetItemUM.Single( id = id, - tokenIconUrl = iconUrl, + icon = TangemIconUM.Currency( + CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.ic_ethereumpow_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), tokenName = name, tokenSymbol = symbol, - accountName = accountName, + fiatRate = "$98,765.43", + cryptoBalance = "1.234 $symbol", + fiatBalance = "$121,876.50", + isBalanceHidden = false, onClick = {}, ) @@ -251,13 +253,8 @@ internal object SearchContentPreviewFixtures { ) private fun portfolioTwo(): ImmutableList = persistentListOf( - userAsset(id = "p1", name = "Ethereum", symbol = "ETH", accountName = "Main wallet"), - userAsset( - id = "p2", - name = "Polygon", - symbol = "POL", - accountName = "Account with a long label for preview", - ), + userAsset(id = "p1", name = "Ethereum", symbol = "ETH"), + userAsset(id = "p2", name = "Polygon", symbol = "POL"), ) private fun marketListShort(): ImmutableList = persistentListOf( @@ -390,6 +387,7 @@ private val SearchContentPreviewCallbacks = SearchCallbacks( onClearHintsClick = {}, onTextHintClick = { _ -> }, onResultMarketTokenClick = { _ -> }, + onHistoryTokenClick = { _ -> }, ) /** All [SearchContentPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */ diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt index e5641b2bb8..9535331da7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchCallbacks.kt @@ -7,4 +7,5 @@ internal data class SearchCallbacks( val onClearHintsClick: () -> Unit, val onTextHintClick: (hint: String) -> Unit, val onResultMarketTokenClick: (MarketsListItemUM) -> Unit, + val onHistoryTokenClick: (MarketsListItemUM) -> Unit, ) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt index e4628ad30c..ede3decb1b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt @@ -1,8 +1,11 @@ package com.tangem.features.feed.ui.search.state import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.domain.models.account.CryptoPortfolioIcon import kotlinx.collections.immutable.ImmutableList data class SearchUM( @@ -42,11 +45,45 @@ sealed interface MarketSearchResultUM { data class TextHintItemUM(val text: String) -data class UserAssetItemUM( - val id: String, - val tokenIconUrl: String?, - val tokenName: String, - val tokenSymbol: String, - val accountName: String, - val onClick: () -> Unit, -) \ No newline at end of file +@Immutable +sealed interface UserAssetItemUM { + val id: String + val icon: TangemIconUM + val tokenName: String + val tokenSymbol: String + val onClick: () -> Unit + + data class Single( + override val id: String, + override val icon: TangemIconUM, + override val tokenName: String, + override val tokenSymbol: String, + val fiatRate: String?, + val cryptoBalance: String, + val fiatBalance: String, + val isBalanceHidden: Boolean, + override val onClick: () -> Unit, + ) : UserAssetItemUM + + data class Grouped( + override val id: String, + override val icon: TangemIconUM, + override val tokenName: String, + override val tokenSymbol: String, + val tokensCount: Int, + val totalCryptoBalance: String, + val totalFiatBalance: String, + val isBalanceHidden: Boolean, + val children: ImmutableList, + override val onClick: () -> Unit, + ) : UserAssetItemUM + + data class GroupedChild( + val walletName: String, + val accountName: AccountNameUM, + val accountIcon: CryptoPortfolioIcon.Icon, + val accountColor: CryptoPortfolioIcon.Color, + val cryptoBalance: String, + val fiatBalance: String, + ) +} \ No newline at end of file From 0896f80f5378dd8821ab03f9987f42c6ca3f47a4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Apr 2026 20:43:14 +0500 Subject: [PATCH 005/206] Updated on 2026-08-14 --- .../components/currency/icon/ContentIcon.kt | 2 + .../components/currency/icon/CurrencyIcon.kt | 1 + .../currency/icon/CurrencyIconState.kt | 7 + .../ui/components/tokenlist/TokenListItem.kt | 10 +- .../ui/ds/opportunities/OpportunitiesBG.kt | 1 + .../DefaultTangemPayCryptoCurrencyFactory.kt | 41 +-- .../PaymentAccountStatusValueDMConverter.kt | 19 +- .../tangem/data/pay/di/TangemPayDataModule.kt | 12 + .../pay/entity/TangemPayCurrencyFactory.kt | 49 ++++ .../DefaultPaymentAccountStatusFetcher.kt | 16 +- .../pay/store/PaymentAccountStatusesStore.kt | 5 +- .../account/PaymentAccountStatusValue.kt | 52 +++- .../pay/TangemPayCryptoCurrencyFactory.kt | 2 +- ...ymentAccountCryptoCurrencyStatusUseCase.kt | 34 +++ .../destination/model/SendDestinationModel.kt | 12 +- .../com/tangem/features/swap/SwapComponent.kt | 4 - .../SavedSwapTransactionListConverter.kt | 8 +- features/swap/domain/build.gradle.kts | 1 + .../feature/swap/domain/SwapInteractor.kt | 4 +- .../feature/swap/domain/SwapInteractorImpl.kt | 66 +++-- .../swap/domain/models/ui/SwapState.kt | 2 +- .../models/ui/TokensDataStateExpress.kt | 4 +- .../choosetoken/api/ChooseTokenComponent.kt | 13 +- .../impl/DefaultChooseTokenBridge.kt | 3 +- .../impl/model/ChooseTokenModel.kt | 11 +- .../converters/AccountTokenItemConverter.kt | 62 ++++- .../swap/converters/TokensDataConverter.kt | 9 +- .../tangem/feature/swap/model/SwapModel.kt | 262 +++++++++--------- .../swap/model/SwapProcessDataState.kt | 4 +- .../tangem/feature/swap/ui/StateBuilder.kt | 30 +- 30 files changed, 483 insertions(+), 263 deletions(-) create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt index 27dceeec39..5927f3e422 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/ContentIcon.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.painterResource import com.tangem.core.ui.R import com.tangem.core.ui.components.account.AccountCharIcon import com.tangem.core.ui.components.account.AccountResIcon +import com.tangem.core.ui.components.account.PaymentAccountIcon import com.tangem.core.ui.components.currency.DefaultCurrencyIcon import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme @@ -61,6 +62,7 @@ internal fun ContentIcon( background = icon.background, alpha = alpha, ) + is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(modifier = modifier, size = icon.size) is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( modifier = modifier, resId = icon.resId, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt index 9ec3a73b8d..f5cb062474 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt @@ -62,6 +62,7 @@ fun CurrencyIcon( is CurrencyIconState.FiatIcon, is CurrencyIconState.CustomTokenIcon, is CurrencyIconState.TokenIcon, + is CurrencyIconState.PaymentAccount, is CurrencyIconState.CryptoPortfolio.Icon, is CurrencyIconState.CryptoPortfolio.Letter, -> { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt index 5bdd5f41f4..505e0d5b67 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconState.kt @@ -88,6 +88,12 @@ sealed class CurrencyIconState { override val topBadgeIconResId: Int? = null } + data class PaymentAccount(val size: AccountIconSize = AccountIconSize.Default) : CurrencyIconState() { + override val isGrayscale: Boolean = false + override val shouldShowCustomBadge: Boolean = false + override val topBadgeIconResId: Int? = null + } + @Immutable sealed class CryptoPortfolio : CurrencyIconState() { override val shouldShowCustomBadge: Boolean = false @@ -155,6 +161,7 @@ sealed class CurrencyIconState { is CryptoPortfolio.Letter -> copy( isGrayscale = isGrayscale, ) + is PaymentAccount, is Loading, is Locked, is Empty, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt index 0c6291b831..7ae2238fb7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/tokenlist/TokenListItem.kt @@ -2,7 +2,8 @@ package com.tangem.core.ui.components.tokenlist import androidx.compose.animation.* import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds -import androidx.compose.animation.core.* +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon @@ -24,6 +25,7 @@ import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.account.AccountCharIcon import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.account.AccountResIcon +import com.tangem.core.ui.components.account.PaymentAccountIcon import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.fields.SearchBar @@ -98,6 +100,9 @@ fun PortfolioListItem(state: TokensListItemUM.Portfolio, isBalanceHidden: Boolea icon.copy( size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default, ) + is CurrencyIconState.PaymentAccount -> icon.copy( + size = if (isExpanded) AccountIconSize.ExtraSmall else AccountIconSize.Default, + ) else -> icon } @@ -183,7 +188,7 @@ fun PortfolioTokensListItem(state: PortfolioTokensListItemUM, isBalanceHidden: B } } -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun ExpandedPortfolioHeader( state: TokenItemState, @@ -209,6 +214,7 @@ fun ExpandedPortfolioHeader( composables.icon.invoke(Modifier) } else { when (val icon = state.iconState) { + is CurrencyIconState.PaymentAccount -> PaymentAccountIcon(size = icon.size) is CurrencyIconState.CryptoPortfolio.Icon -> AccountResIcon( resId = icon.resId, color = icon.color, diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt index ac427c9961..e3750fd53e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/opportunities/OpportunitiesBG.kt @@ -169,6 +169,7 @@ private fun BoxScope.CurrencyIconBackgroundLayer(state: CurrencyIconState, blurR blurRadius = blurRadius, ) } + is CurrencyIconState.PaymentAccount -> Unit CurrencyIconState.Loading -> Unit CurrencyIconState.Locked -> Unit } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt index 48607769ea..ca7b011917 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayCryptoCurrencyFactory.kt @@ -7,8 +7,8 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.core.error.UniversalError import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.data.pay.util.TangemPayErrorConverter -import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory @@ -16,14 +16,8 @@ import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val TAG = "TangemPay: DefaultTangemPayCryptoCurrencyFactory" -/** - * Custom token parameters. Will be used only for F&F. - */ -private const val TOKEN_ID = "usd-coin" -private const val TOKEN_NAME = "USDC" -private const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" -private const val TOKEN_DECIMALS = 6 +@Deprecated("Use TangemPayCurrencyFactory instead") internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor( excludedBlockchains: ExcludedBlockchains, private val errorConverter: TangemPayErrorConverter, @@ -47,32 +41,11 @@ internal class DefaultTangemPayCryptoCurrencyFactory @Inject constructor( ) cryptoCurrencyFactory.createToken( network = requireNotNull(network), - rawId = CryptoCurrency.RawID(TOKEN_ID), - name = TOKEN_NAME, - symbol = TOKEN_NAME, - contractAddress = TOKEN_CONTRACT_ADDRESS, - decimals = TOKEN_DECIMALS, - ) - }.mapLeft { exception -> - TangemLogger.withTag(TAG).e("Error", exception) - errorConverter.convert(exception) - } - } - - override fun create(userWallet: UserWallet): Either { - return catch { - val network = networkFactory.create( - blockchain = VisaUtilities.visaBlockchain, - extraDerivationPath = null, - userWallet = userWallet, - ) - cryptoCurrencyFactory.createToken( - network = requireNotNull(network), - rawId = CryptoCurrency.RawID(TOKEN_ID), - name = TOKEN_NAME, - symbol = TOKEN_NAME, - contractAddress = TOKEN_CONTRACT_ADDRESS, - decimals = TOKEN_DECIMALS, + rawId = CryptoCurrency.RawID(TangemPayCurrencyFactory.TOKEN_ID), + name = TangemPayCurrencyFactory.TOKEN_NAME, + symbol = TangemPayCurrencyFactory.TOKEN_NAME, + contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS, + decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, ) }.mapLeft { exception -> TangemLogger.withTag(TAG).e("Error", exception) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index a42852436a..57eb9b4402 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -1,11 +1,12 @@ package com.tangem.data.pay.converter -import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convert -import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter.convertBack +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue -import com.tangem.utils.converter.TwoWayConverter +import com.tangem.domain.models.wallet.UserWalletId +import javax.inject.Inject +import javax.inject.Singleton /** * Two-way converter between [PaymentAccountStatusValue] and [PaymentAccountStatusValueDM]. @@ -15,10 +16,12 @@ import com.tangem.utils.converter.TwoWayConverter * * [convertBack] maps data model → domain. All restored statuses have [StatusSource.CACHE] as source. */ -internal object PaymentAccountStatusValueDMConverter : - TwoWayConverter { +@Singleton +internal class PaymentAccountStatusValueDMConverter @Inject constructor( + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, +) { - override fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? { + fun convert(value: PaymentAccountStatusValue): PaymentAccountStatusValueDM? { return when (value) { is PaymentAccountStatusValue.NotCreated -> PaymentAccountStatusValueDM.NotCreated() is PaymentAccountStatusValue.UnderReview -> PaymentAccountStatusValueDM.UnderReview( @@ -60,7 +63,7 @@ internal object PaymentAccountStatusValueDMConverter : } } - override fun convertBack(value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { + fun convertBack(userWalletId: UserWalletId, value: PaymentAccountStatusValueDM?): PaymentAccountStatusValue { return when (value) { is PaymentAccountStatusValueDM.NotCreated -> PaymentAccountStatusValue.NotCreated is PaymentAccountStatusValueDM.CardIssueFailed -> PaymentAccountStatusValue.Error.CardIssueFailed( @@ -80,6 +83,7 @@ internal object PaymentAccountStatusValueDMConverter : isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), ) } else { PaymentAccountStatusValue.Loaded( @@ -92,6 +96,7 @@ internal object PaymentAccountStatusValueDMConverter : isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), ) } is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 3887f44e6f..d697221c1b 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -6,6 +6,7 @@ import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory import com.tangem.data.pay.DefaultTangemPayEligibilityManager +import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer import com.tangem.data.pay.repository.* @@ -24,6 +25,7 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusProducer import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.* +import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase @@ -112,6 +114,7 @@ internal interface TangemPayDataModule { @ApplicationContext context: Context, dispatchers: CoroutineDispatcherProvider, scope: AppCoroutineScope, + converter: PaymentAccountStatusValueDMConverter, ): PaymentAccountStatusesStore { return PaymentAccountStatusesStore( runtimeStore = RuntimeSharedStore(), @@ -124,6 +127,7 @@ internal interface TangemPayDataModule { produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") }, scope = scope, ), + converter = converter, scope = scope, ) } @@ -139,6 +143,14 @@ internal interface TangemPayDataModule { ) {} } + @Provides + @Singleton + fun provideGetTangemPayCryptoCurrencyStatusUseCase( + paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + ): GetPaymentAccountCryptoCurrencyStatusUseCase { + return GetPaymentAccountCryptoCurrencyStatusUseCase(paymentAccountStatusSupplier) + } + @Provides @Singleton fun provideTangemPayMainScreenCustomerInfoUseCase( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt new file mode 100644 index 0000000000..ede6bba797 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/TangemPayCurrencyFactory.kt @@ -0,0 +1,49 @@ +package com.tangem.data.pay.entity + +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.common.currency.CryptoCurrencyFactory +import com.tangem.data.common.network.NetworkFactory +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.requireUserWalletsSync +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class TangemPayCurrencyFactory @Inject constructor( + excludedBlockchains: ExcludedBlockchains, + private val userWalletsListRepository: UserWalletsListRepository, + private val networkFactory: NetworkFactory, +) { + private val cryptoCurrencyFactory by lazy(mode = LazyThreadSafetyMode.NONE) { + CryptoCurrencyFactory(excludedBlockchains) + } + + fun create(userWalletId: UserWalletId): CryptoCurrency.Token { + val userWallet = userWalletsListRepository.requireUserWalletsSync() + .firstOrNull { it.walletId == userWalletId } + ?: error("User wallet with id $userWalletId not found") + val network = networkFactory.create( + blockchain = VisaUtilities.visaBlockchain, + userWallet = userWallet, + extraDerivationPath = null, + ) + return cryptoCurrencyFactory.createToken( + network = requireNotNull(network), + rawId = CryptoCurrency.RawID(TOKEN_ID), + name = TOKEN_NAME, + symbol = TOKEN_NAME, + contractAddress = TOKEN_CONTRACT_ADDRESS, + decimals = TOKEN_DECIMALS, + ) + } + + companion object { + internal const val TOKEN_ID = "usd-coin" + internal const val TOKEN_NAME = "USDC" + internal const val TOKEN_CONTRACT_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" + internal const val TOKEN_DECIMALS = 6 + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index cdd78706de..9e0ae38e91 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.data.pay.flow import arrow.core.Either +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.domain.core.utils.catchOn import com.tangem.domain.models.StatusSource @@ -8,6 +9,7 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.OrderStatus @@ -29,6 +31,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val customerOrderRepository: CustomerOrderRepository, private val deviceSecurity: DeviceSecurityInfoProvider, private val dispatchers: CoroutineDispatcherProvider, + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, ) : PaymentAccountStatusFetcher { private val logger = TangemLogger.withTag(TAG) @@ -132,7 +135,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( }, ifRight = { customerInfo -> logger.i("proceedWithoutOrder data customerInfo ${account.userWalletId}") - val status = customerInfo.mapToPaymentAccountStatus() + val status = customerInfo.mapToPaymentAccountStatus(account.userWalletId) if (status is PaymentAccountStatusValue.IssuingCard && customerInfo.kycStatus == KycStatus.APPROVED) { // If order id wasn't saved -> start order creation and get customer info onboardingRepository.createOrder(account.userWalletId) @@ -167,7 +170,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( }, ifRight = { customerInfo -> if (customerInfo.kycStatus == KycStatus.REJECTED) { - customerInfo.mapToPaymentAccountStatus() + customerInfo.mapToPaymentAccountStatus(account.userWalletId) } else { PaymentAccountStatusValue.Error.CardIssueFailed( customerId = orderData.customerId, @@ -182,7 +185,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( onboardingRepository.getCustomerInfo(userWalletId = account.userWalletId) .fold( ifLeft = { it.mapToPaymentAccountStatus() }, - ifRight = { customerInfo -> customerInfo.mapToPaymentAccountStatus() }, + ifRight = { it.mapToPaymentAccountStatus(account.userWalletId) }, ) } OrderStatus.UNKNOWN -> PaymentAccountStatusValue.Error.Unavailable @@ -191,7 +194,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } - private fun CustomerInfo.mapToPaymentAccountStatus(): PaymentAccountStatusValue { + private fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { val cardInfo = this.cardInfo val productInstance = this.productInstance return if (kycStatus != KycStatus.APPROVED && !customerId.isNullOrEmpty()) { @@ -202,6 +205,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } else if (cardInfo != null && productInstance != null && !customerId.isNullOrEmpty()) { convertToContentState( + userWalletId = userWalletId, productInstance = productInstance, cardInfo = cardInfo, customerId = requireNotNull(customerId) { "CustomerId must not be null" }, @@ -212,10 +216,12 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( } private fun convertToContentState( + userWalletId: UserWalletId, productInstance: CustomerInfo.ProductInstance, cardInfo: CustomerInfo.CardInfo, customerId: String, ): PaymentAccountStatusValue { + val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId) return when (productInstance.frozenState) { TangemPayCardFrozenState.Frozen -> PaymentAccountStatusValue.Locked( source = StatusSource.ACTUAL, @@ -227,6 +233,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( isPinSet = cardInfo.isPinSet, fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, + cryptoCurrency = cryptoCurrency, ) else -> PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, @@ -238,6 +245,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( isPinSet = cardInfo.isPinSet, fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, + cryptoCurrency = cryptoCurrency, ) } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index ec8a48d880..62accbccf7 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -29,6 +29,7 @@ internal typealias WalletIdWithPaymentStatusDM = Map, private val persistenceDataStore: DataStore, + private val converter: PaymentAccountStatusValueDMConverter, scope: AppCoroutineScope, ) { @@ -39,7 +40,7 @@ internal class PaymentAccountStatusesStore( runtimeStore.store( value = cachedStatuses.mapValues { (rawUserWalletId, statusDM) -> val account = Account.Payment(userWalletId = UserWalletId(rawUserWalletId)) - val statusValue = PaymentAccountStatusValueDMConverter.convertBack(value = statusDM) + val statusValue = converter.convertBack(userWalletId = account.userWalletId, value = statusDM) AccountStatus.Payment(account = account, value = statusValue) }, ) @@ -87,7 +88,7 @@ internal class PaymentAccountStatusesStore( } private suspend fun storeInPersistence(userWalletId: UserWalletId, status: PaymentAccountStatusValue) { - val statusDM = PaymentAccountStatusValueDMConverter.convert(value = status) ?: return + val statusDM = converter.convert(value = status) ?: return persistenceDataStore.updateData { storedStatuses -> storedStatuses.toMutableMap().apply { put(key = userWalletId.stringValue, value = statusDM) diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index fc44ccc105..53beaf23be 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -2,9 +2,13 @@ package com.tangem.domain.models.account import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.network.NetworkAddress import com.tangem.domain.models.serialization.SerializedBigDecimal import kotlinx.serialization.Serializable +import java.math.BigDecimal /** * Represents the various states a payment account can have, encapsulating different information based on the state. @@ -104,7 +108,29 @@ sealed class PaymentAccountStatusValue { val isPinSet: Boolean, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, - ) : PaymentAccountStatusValue() + val cryptoCurrency: CryptoCurrency.Token, + ) : PaymentAccountStatusValue() { + val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = cryptoCurrency, + value = CryptoCurrencyStatus.Loaded( + amount = cryptoBalance.balance, + fiatAmount = fiatBalance.availableBalance, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + type = NetworkAddress.Address.Type.Primary, + value = cryptoBalance.depositAddress, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + pendingTransactions = emptySet(), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + ), + ) + } /** * Represents a state where the payment account is successfully loaded with complete information. @@ -130,7 +156,29 @@ sealed class PaymentAccountStatusValue { val isPinSet: Boolean, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, - ) : PaymentAccountStatusValue() + val cryptoCurrency: CryptoCurrency.Token, + ) : PaymentAccountStatusValue() { + val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = cryptoCurrency, + value = CryptoCurrencyStatus.Loaded( + amount = cryptoBalance.balance, + fiatAmount = fiatBalance.availableBalance, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + type = NetworkAddress.Address.Type.Primary, + value = cryptoBalance.depositAddress, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + pendingTransactions = emptySet(), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + ), + ) + } /** Represents an error state for the payment account status. */ @Serializable diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt index 1004e00447..31a2537914 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCryptoCurrencyFactory.kt @@ -5,8 +5,8 @@ import com.tangem.core.error.UniversalError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +@Deprecated("TangemPayCurrencyFactory") interface TangemPayCryptoCurrencyFactory { fun create(userWallet: UserWallet, chainId: Int): Either - fun create(userWallet: UserWallet): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt new file mode 100644 index 0000000000..fe66dc36f7 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt @@ -0,0 +1,34 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Option +import arrow.core.none +import arrow.core.some +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import kotlinx.coroutines.flow.firstOrNull + +class GetPaymentAccountCryptoCurrencyStatusUseCase( + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, +) { + + suspend operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): Option> { + val accountStatus = paymentAccountStatusSupplier.invoke(userWalletId).firstOrNull() ?: return none() + val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + is PaymentAccountStatusValue.Locked -> statusValue.cryptoCurrencyStatus + else -> return none() + } + return if (cryptoCurrencyStatus.currency == cryptoCurrency) { + (accountStatus.account to cryptoCurrencyStatus).some() + } else { + none() + } + } +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 18ff42ff9e..df6dc83590 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -19,7 +19,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.CryptoCurrencyAddress import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isLocked -import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase @@ -69,7 +68,6 @@ internal class SendDestinationModel @Inject constructor( private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val parseQrCodeUseCase: ParseQrCodeUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - private val tangemPayCryptoCurrencyFactory: TangemPayCryptoCurrencyFactory, private val analyticsEventHandler: AnalyticsEventHandler, private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, ) : Model(), SendDestinationClickIntents { @@ -260,16 +258,16 @@ internal class SendDestinationModel @Inject constructor( private fun AccountStatus.Payment.getDestinationWalletUM(wallet: UserWallet): DestinationWalletUM? { val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null - val address = when (val status = this.value) { - is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress - is PaymentAccountStatusValue.Locked -> status.cryptoBalance.depositAddress + val (paymentAccountAddress, currency) = when (val status = this.value) { + is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress to status.cryptoCurrency + is PaymentAccountStatusValue.Locked -> status.cryptoBalance.depositAddress to status.cryptoCurrency else -> return null } - val currency = tangemPayCryptoCurrencyFactory.create(wallet).getOrNull() ?: return null + return if (contractAddress.equals(currency.contractAddress, true)) { DestinationWalletUM( name = wallet.name, - address = address, + address = paymentAccountAddress, cryptoCurrency = currency, userWalletId = wallet.walletId, account = account, diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index ed9144cac3..2d0f54f7b2 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -2,9 +2,7 @@ package com.tangem.features.swap import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -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.wallet.UserWalletId import java.math.BigDecimal @@ -17,8 +15,6 @@ interface SwapComponent : ComposableContentComponent { val isInitialReverseOrder: Boolean = false, val screenSource: String, val tangemPayInput: TangemPayInput? = null, - val preselectedToToken: CryptoCurrencyStatus? = null, - val preselectedAccount: Account? = null, ) { data class TangemPayInput( val cryptoAmount: BigDecimal, diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt index 2b50f90d14..dba1d47550 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt @@ -162,8 +162,12 @@ internal class SavedSwapTransactionListConverter( } private fun findAccountByDerivationIndex(accountList: AccountList?, derivationIndex: DerivationIndex?): Account? { - return accountList?.accounts?.asSequence()?.filterIsInstance() - ?.firstOrNull { it.derivationIndex == derivationIndex } + val accounts = accountList?.accounts ?: return null + + return accounts.asSequence() + .filterIsInstance() + .firstOrNull { it.derivationIndex == derivationIndex } + ?: accounts.firstOrNull { it is Account.Payment }.takeIf { derivationIndex == null } } private fun UserTokensResponse.Token.getDerivationIndex(): DerivationIndex? { diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index d0baa14afb..b76d4c6e4c 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(projects.domain.express.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.visa) implementation(projects.domain.visa.models) implementation(projects.features.swap.domain.api) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index 98165323aa..ae3c82c978 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -49,9 +49,9 @@ interface SwapInteractor { @Throws(IllegalStateException::class) suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index e44392c352..18dc50d0b6 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -31,7 +31,7 @@ import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -134,13 +134,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun getAccountCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( SingleAccountStatusListProducer.Params(userWalletId), - )?.accountStatuses.orEmpty().filterCryptoPortfolio() + )?.accountStatuses.orEmpty() val walletAccountCurrencyStatusesExceptInitial: Map> = walletAccountCurrencyStatuses.mapNotNull { accountStatus -> val filteredCurrencies = when (accountStatus) { is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies().filterCurrencies(currency) - is AccountStatus.Payment -> TODO("[REDACTED_JIRA]") + is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus) } if (filteredCurrencies.isNotEmpty()) { @@ -184,6 +184,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { + val currencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + is PaymentAccountStatusValue.Locked -> statusValue.cryptoCurrencyStatus + else -> return emptyList() + } + + return listOf(currencyStatus) + } + private fun List.filterCurrencies(currency: CryptoCurrency) = this.filter { status -> val isDifferentCurrency = status.currency.network.backendId != currency.network.backendId || status.currency.getContractAddress() != currency.getContractAddress() @@ -207,11 +217,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( tokenInfoForFilter(pair).network == currency.network.backendId } - val accountCurrencyList = cryptoCurrenciesList.mapNotNull { (accountEntry, currencyStatusList) -> - val cryptoPortfolio = accountEntry as? Account.CryptoPortfolio ?: return@mapNotNull null - + val accountCurrencyList = cryptoCurrenciesList.map { (accountEntry, currencyStatusList) -> AccountSwapAvailability( - account = cryptoPortfolio, + account = accountEntry, currencyList = currencyStatusList.map { currencyStatus -> val providers = findProvidersForPair( cryptoCurrencyStatuses = currencyStatus, @@ -314,11 +322,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } + @Suppress("LongMethod") override suspend fun findBestQuote( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -343,7 +352,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( return providers.associateWith { createEmptyAmountState() } } val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) - val isBalanceWithoutFeeEnough = isBalanceEnough(fromToken, amount, null) + val isBalanceWithoutFeeEnough = when (fromAccount) { + is Account.Payment -> true + else -> isBalanceEnough(fromToken, amount, null) + } val networkId = fromToken.currency.network.backendId when (provider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { @@ -397,9 +409,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDex( networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, @@ -480,9 +492,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageDexSolana( networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, @@ -535,9 +547,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun manageCex( networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, amount: SwapAmount, reduceBalanceBy: BigDecimal, @@ -1292,9 +1304,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount: SwapAmount, reduceBalanceBy: BigDecimal, fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toTokenStatus: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1386,9 +1398,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( quoteDataModel: Either, amount: SwapAmount, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, networkId: String, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, @@ -1500,7 +1512,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun createSwapErrorWith( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, @@ -1690,9 +1702,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, networkId: String, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, amount: SwapAmount, txFeeSealedState: TxFeeSealedState, expressOperationType: ExpressOperationType, @@ -1834,7 +1846,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun produceDexSwapDataError( error: ExpressDataError, fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, amount: SwapAmount, ): SwapState.SwapError { val rates = getQuotes(fromToken.currency.id) @@ -1931,9 +1943,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun updateBalances( provider: SwapProvider, fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toTokenStatus: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, @@ -1999,7 +2011,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun updatePermissionState( networkId: String, fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, spenderAddress: String?, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 89f79b2d72..d538156362 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -101,7 +101,7 @@ data class TokenSwapInfo( val tokenAmount: SwapAmount, val amountFiat: BigDecimal, val cryptoCurrencyStatus: CryptoCurrencyStatus, - val account: Account.CryptoPortfolio?, + val account: Account?, ) data class RequestApproveStateData( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt index f0587337d6..d2da881490 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/TokensDataStateExpress.kt @@ -45,13 +45,13 @@ data class CurrenciesGroup( ) data class AccountSwapAvailability( - val account: Account.CryptoPortfolio, + val account: Account, val currencyList: List, ) data class AccountSwapCurrency( val isAvailable: Boolean, - val account: Account.CryptoPortfolio, + val account: Account, val cryptoCurrencyStatus: CryptoCurrencyStatus, val providers: List, ) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt index e87799d55e..c421ec5874 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt @@ -4,6 +4,7 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -23,14 +24,14 @@ internal interface ChooseTokenBridge { val onClose: Channel // todo swap legacy api, remove - val onTokenSelected: Channel> + val onTokenSelected: Channel val onNewTokenAdded: Channel> val searchQueryState: StateFlow val currenciesGroup: Flow - fun onTokenSelected(tokenId: Pair) { - onTokenSelected.trySend(tokenId) + fun onTokenSelected(result: ChooseTokenResultOld) { + onTokenSelected.trySend(result) onSearchQuery("") } @@ -57,6 +58,12 @@ internal interface ChooseTokenBridge { } } +data class ChooseTokenResultOld( + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val account: Account, + val isSearched: Boolean, +) + data class ChooseTokenResult( val currency: CryptoCurrencyStatus, val account: AccountStatus, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt index 870a5a7abe..a8b7cb6142 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt @@ -4,6 +4,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult +import com.tangem.feature.swap.choosetoken.api.ChooseTokenResultOld import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup import dagger.assisted.Assisted @@ -19,7 +20,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( override val onCurrencyChosen: Channel = Channel() - override val onTokenSelected: Channel> = Channel() + override val onTokenSelected: Channel = Channel() override val onNewTokenAdded: Channel> = Channel() override val onClose: Channel = Channel() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt index ca32af6c5e..0766482b98 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt @@ -116,10 +116,13 @@ internal class ChooseTokenModel @Inject constructor( TokensDataConverter( onSearchEntered = { query -> bridge.onSearchQuery(query) }, - onTokenClick = { tokenId -> - val selected = tokenId to ChooseTokenAnalyticsPayload - .IsSearched(isSearchingState) - bridge.onTokenSelected(selected) + onTokenClick = { account, cryptoCurrencyStatus -> + val result = ChooseTokenResultOld( + account = account, + cryptoCurrencyStatus = cryptoCurrencyStatus, + isSearched = searchQueryState.value.isNotEmpty(), + ) + bridge.onTokenSelected(result) }, onAccountClick = { account -> expandedAccountsFlow.update { expandedList -> diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt index 0bb771929f..333bb5087c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt @@ -2,15 +2,21 @@ package com.tangem.feature.swap.converters import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount +import com.tangem.common.ui.R import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter import com.tangem.common.ui.account.TokensListPortfolioItemConverter +import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.pluralReference import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -29,34 +35,68 @@ internal class AccountTokenItemConverter( private val appCurrency: AppCurrency, private val unavailableErrorText: TextReference, private val expandedAccounts: Map, - private val onTokenItemClick: (String) -> Unit, - private val onAccountItemClick: (Account.CryptoPortfolio) -> Unit, + private val onTokenItemClick: (Account, CryptoCurrencyStatus) -> Unit, + private val onAccountItemClick: (Account) -> Unit, ) : Converter { override fun convert(value: AccountSwapAvailability): TokensListItemUM.Portfolio { - return TokensListPortfolioItemConverter( - tokenItemUM = AccountCryptoPortfolioItemStateConverter( + val headerTokenItemState = when (val account = value.account) { + is Account.CryptoPortfolio -> AccountCryptoPortfolioItemStateConverter( appCurrency = appCurrency, - account = value.account.copy( - cryptoCurrencies = value.currencyList.map { it.cryptoCurrencyStatus.currency }, - ), + account = account.copy(cryptoCurrencies = value.currencyList.map { it.cryptoCurrencyStatus.currency }), onItemClick = onAccountItemClick, ).convert( TotalFiatBalance.Loaded( amount = value.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() }, source = StatusSource.ONLY_CACHE, ), - ), + ) + is Account.Payment -> createPaymentAccountHeaderState(value) + } + return TokensListPortfolioItemConverter( + tokenItemUM = headerTokenItemState, isExpanded = expandedAccounts[value.account.accountId] != false, isCollapsable = true, tokens = value.currencyList.map { accountSwapCurrency -> - createAvailableItemConverter() + createAvailableItemConverter(value.account) .convert(accountSwapCurrency.cryptoCurrencyStatus) }.map(TokensListItemUM::Token).toPersistentList(), ).convert(Unit) } - fun createAvailableItemConverter(): TokenItemStateConverter { + private fun createPaymentAccountHeaderState(accountSwapAvailability: AccountSwapAvailability): TokenItemState { + val account = accountSwapAvailability.account + val tokensCount = accountSwapAvailability.currencyList.size + val fiatBalance = + accountSwapAvailability.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() } + return TokenItemState.Content( + id = account.accountId.value, + iconState = CurrencyIconState.PaymentAccount(), + titleState = TokenItemState.TitleState.Content(text = account.accountName.toUM().value), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + onItemClick = { onAccountItemClick(account) }, + fiatAmountState = FiatAmountState.Content( + text = fiatBalance.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + isFlickering = false, + ), + subtitle2State = null, + onItemLongClick = null, + ) + } + + fun createAvailableItemConverter(account: Account): TokenItemStateConverter { return TokenItemStateConverter( appCurrency = appCurrency, subtitleStateProvider = { status -> @@ -70,7 +110,7 @@ internal class AccountTokenItemConverter( fiatAmountStateProvider = { createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true) }, - onItemClick = { account, currencyStatus -> onTokenItemClick(currencyStatus.currency.id.value) }, + onItemClick = { _, currencyStatus -> onTokenItemClick(account, currencyStatus) }, ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt index 64ba7cf5b0..d06953f1a3 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.TokenListUMData @@ -16,10 +17,10 @@ import kotlinx.collections.immutable.toPersistentList @Suppress("LongParameterList") internal class TokensDataConverter( - private val onSearchEntered: (String) -> Unit, - onTokenClick: (String) -> Unit, - onAccountClick: (Account.CryptoPortfolio) -> Unit, + onTokenClick: (Account, CryptoCurrencyStatus) -> Unit, + onAccountClick: (Account) -> Unit, private val expandedAccounts: Map, + private val onSearchEntered: (String) -> Unit, private val tokensDataState: CurrenciesGroup, private val isBalanceHidden: Boolean, private val isAccountsMode: Boolean, @@ -52,7 +53,7 @@ internal class TokensDataConverter( } else { val tokensList = accountList.flatMap { (_, currencyList) -> currencyList.asSequence().map { accountSwapCurrency -> - accountListItemConverter.createAvailableItemConverter() + accountListItemConverter.createAvailableItemConverter(accountSwapCurrency.account) .convert(accountSwapCurrency.cryptoCurrencyStatus) }.map(TokensListItemUM::Token).toPersistentList() }.toPersistentList() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index dcdc0a63a5..41203dc1f7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -33,7 +33,6 @@ import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.utils.InputNumberFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore -import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase @@ -49,12 +48,14 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.derivationIndex 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.wallet.UserWalletId import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.pay.WithdrawalResult +import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.promo.ShouldShowStoriesUseCase import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.settings.usercountry.GetUserCountryUseCase @@ -144,6 +145,7 @@ internal class SwapModel @Inject constructor( private val appsFlyerStore: AppsFlyerStore, private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, private val messageSender: UiMessageSender, + private val paymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, chooseTokenBridgeFactory: ChooseTokenBridge.Factory, giveApprovalFeatureToggles: GiveApprovalFeatureToggles, ) : Model() { @@ -219,8 +221,10 @@ internal class SwapModel @Inject constructor( private val swapRouter: SwapRouter = SwapRouter(router = router) private var userCountry: UserCountry? = null - private var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null - private var toAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null + private var fromAccount: Account? = null + private var toAccount: Account? = null + private var fromAccountStatus: CryptoCurrencyStatus? = null + private var toAccountStatus: CryptoCurrencyStatus? = null /** * If user came from Tangem Pay -> fromAccountCurrencyStatus == null @@ -286,8 +290,12 @@ internal class SwapModel @Inject constructor( .launchIn(modelScope) chooseTokenBridge.onTokenSelected.receiveAsFlow() - .onEach { (addedToken, isSearched) -> - onTokenSelect(addedToken, isSearched.value) + .onEach { result -> + onTokenSelect( + account = result.account, + cryptoCurrencyStatus = result.cryptoCurrencyStatus, + isSearched = result.isSearched, + ) } .launchIn(modelScope) @@ -322,22 +330,31 @@ internal class SwapModel @Inject constructor( userWalletId = userWalletId, currency = initialCurrencyFrom, ).getOrNull() + val fromPaymentAccountStatus = + paymentAccountCryptoCurrencyStatusUseCase(userWalletId, initialCurrencyFrom).getOrNull() val toAccountStatus = initialCurrencyTo?.let { currencyTo -> getAccountCurrencyStatusUseCase.invokeSync( userWalletId = userWalletId, currency = currencyTo, ).getOrNull() } + val toPaymentAccountStatus = initialCurrencyTo?.let { currencyTo -> + paymentAccountCryptoCurrencyStatusUseCase(userWalletId, currencyTo).getOrNull() + } + val fromAccount = fromAccountStatus?.account ?: fromPaymentAccountStatus?.first + val fromStatus = fromAccountStatus?.status ?: fromPaymentAccountStatus?.second - if (fromAccountStatus == null) { + if (fromAccount != null && fromStatus != null) { + this@SwapModel.fromAccount = fromAccount + this@SwapModel.fromAccountStatus = fromStatus + this@SwapModel.toAccount = toAccountStatus?.account ?: toPaymentAccountStatus?.first + this@SwapModel.toAccountStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second + this@SwapModel.initialFromStatus = fromStatus + this@SwapModel.initialToStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second + initTokens(isInitiallyReversed) + } else { showAlert() swapRouter.back() - } else { - fromAccountCurrencyStatus = fromAccountStatus - toAccountCurrencyStatus = toAccountStatus - initialFromStatus = fromAccountStatus.status - initialToStatus = toAccountStatus?.status - initTokens(isInitiallyReversed) } } else { val fromStatus = getFromStatus() @@ -405,7 +422,7 @@ internal class SwapModel @Inject constructor( updateTokensState(state) val (selectedCurrency, selectedAccount) = run { - var selectedAccountCurrency = toAccountCurrencyStatus + var selectedAccountCurrency = toAccountStatus if (selectedAccountCurrency == null) { val amountSwapCurrency = swapInteractor.getInitialCurrencyToSwap( @@ -415,14 +432,11 @@ internal class SwapModel @Inject constructor( ) if (amountSwapCurrency != null) { - selectedAccountCurrency = AccountCryptoCurrencyStatus( - account = amountSwapCurrency.account, - status = amountSwapCurrency.cryptoCurrencyStatus, - ) + selectedAccountCurrency = amountSwapCurrency.cryptoCurrencyStatus } } - selectedAccountCurrency?.status to selectedAccountCurrency?.account + selectedAccountCurrency to toAccount } val isApplied = applyInitialTokenChoice( @@ -534,7 +548,7 @@ internal class SwapModel @Inject constructor( private fun applyInitialTokenChoice( state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?, - selectedAccount: Account.CryptoPortfolio?, + selectedAccount: Account?, isReverseFromTo: Boolean, ): Boolean { // exceptional case @@ -555,9 +569,9 @@ internal class SwapModel @Inject constructor( } val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) { if (isOrderReversed.value) { - selectedAccount to requireNotNull(fromAccountCurrencyStatus).account + selectedAccount to fromAccount } else { - requireNotNull(fromAccountCurrencyStatus).account to selectedAccount + fromAccount to selectedAccount } } else { null to null @@ -600,9 +614,9 @@ internal class SwapModel @Inject constructor( private fun startLoadingQuotes( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -673,9 +687,9 @@ internal class SwapModel @Inject constructor( private fun loadQuotesTask( fromToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, toToken: CryptoCurrencyStatus, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -777,7 +791,7 @@ internal class SwapModel @Inject constructor( selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), - hideFee = tangemPayInput?.isWithdrawal == true, + hideFee = isTangemPayWithdrawal(), ) } @@ -971,8 +985,9 @@ internal class SwapModel @Inject constructor( } val fromCurrency = requireNotNull(dataState.fromCryptoCurrency) val fee = getSelectedFee() + val isTangemPayWithdrawal = isTangemPayWithdrawal() - if (fee == null && tangemPayInput?.isWithdrawal != true) { + if (fee == null && !isTangemPayWithdrawal) { TangemLogger.e("onSwapClick: fee is null and isWithdrawal is ${tangemPayInput?.isWithdrawal}") showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) modelScope.launch { @@ -994,7 +1009,7 @@ internal class SwapModel @Inject constructor( includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = fee, expressOperationType = ExpressOperationType.SWAP, - isTangemPayWithdrawal = tangemPayInput?.isWithdrawal == true, + isTangemPayWithdrawal = isTangemPayWithdrawal, ) }.onSuccess { swapTransactionState -> when (swapTransactionState) { @@ -1252,11 +1267,12 @@ internal class SwapModel @Inject constructor( } @Suppress("LongMethod") - private fun onTokenSelect(id: String, isSearched: Boolean) { + private fun onTokenSelect(account: Account, cryptoCurrencyStatus: CryptoCurrencyStatus, isSearched: Boolean) { val tokens = dataState.tokensDataState ?: return - val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id) + val foundToken = cryptoCurrencyStatus + val foundAccount = account - foundToken?.currency?.symbol?.let { symbol -> + foundToken.currency.symbol.let { symbol -> analyticsEventHandler.send( SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = symbol), ) @@ -1270,108 +1286,90 @@ internal class SwapModel @Inject constructor( ) } - if (foundToken != null) { - val fromToken: CryptoCurrencyStatus - val fromAccount: Account.CryptoPortfolio? - val toToken: CryptoCurrencyStatus - val toAccount: Account.CryptoPortfolio? - if (isOrderReversed.value) { - fromToken = foundToken - fromAccount = foundAccount - toToken = initialFromStatus - toAccount = fromAccountCurrencyStatus?.account + val fromToken: CryptoCurrencyStatus + val fromAccount: Account? + val toToken: CryptoCurrencyStatus + val toAccount: Account? + if (isOrderReversed.value) { + fromToken = foundToken + fromAccount = foundAccount + toToken = initialFromStatus + toAccount = this.fromAccount - val newToken = fromToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = true, - ) - } else { - fromTokenBalanceJobHolder.cancel() - } + val newToken = fromToken.currency as? CryptoCurrency.Coin + if (newToken != null) { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = newToken, + isFromCurrency = true, + ) } else { - fromToken = initialFromStatus - fromAccount = fromAccountCurrencyStatus?.account - toToken = foundToken - toAccount = foundAccount - - val newToken = toToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = false, - ) - } else { - toTokenBalanceJobHolder.cancel() - } + fromTokenBalanceJobHolder.cancel() } - - if (dataState.fromCryptoCurrency != null && dataState.tokensDataState != null) { - isAmountChangedByUser = true - } - - dataState = dataState.copy( - fromCryptoCurrency = fromToken, - fromAccount = fromAccount, - toCryptoCurrency = toToken, - toAccount = toAccount, - selectedProvider = null, - ) - swapRouter.openScreen(SwapNavScreen.Main) - if (handleSwapNotSupported( - state = tokens, - fromToken = fromToken, - toToken = toToken, - fromAccount = fromAccount, - toAccount = toAccount, - ) - ) { - return - } - modelScope.launch { - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " + - "isOrderReversed: ${isOrderReversed.value}", - ) - if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) { - uiState = stateBuilder.createInitialLoadingState( - initialCurrencyFrom = fromToken.currency, - initialCurrencyTo = toToken.currency, - fromNetworkInfo = fromToken.currency.getNetworkInfo(), - ) - } - updateFeePaidCryptoCurrencyFor(fromToken) - startLoadingQuotes( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromToken, toToken), - ) - } - updateTokensState(tokens) - } - } - - private fun getSelectedTokenAndAccount( - tokens: TokensDataStateExpress, - id: String, - ): Pair { - val accountCryptoCurrencyStatus = if (isOrderReversed.value) { - tokens.fromGroup } else { - tokens.toGroup - }.accountCurrencyList.firstNotNullOfOrNull { accountSwapAvailability -> - accountSwapAvailability.currencyList.firstOrNull { accountSwapCurrency -> - accountSwapCurrency.cryptoCurrencyStatus.currency.id.value == id + fromToken = initialFromStatus + fromAccount = this.fromAccount + toToken = foundToken + toAccount = foundAccount + + val newToken = toToken.currency as? CryptoCurrency.Coin + if (newToken != null) { + subscribeToCoinBalanceUpdates( + userWalletId = userWalletId, + coin = newToken, + isFromCurrency = false, + ) + } else { + toTokenBalanceJobHolder.cancel() } } - return accountCryptoCurrencyStatus?.cryptoCurrencyStatus to accountCryptoCurrencyStatus?.account + + if (dataState.fromCryptoCurrency != null && dataState.tokensDataState != null) { + isAmountChangedByUser = true + } + + dataState = dataState.copy( + fromCryptoCurrency = fromToken, + fromAccount = fromAccount, + toCryptoCurrency = toToken, + toAccount = toAccount, + selectedProvider = null, + ) + swapRouter.openScreen(SwapNavScreen.Main) + if (handleSwapNotSupported( + state = tokens, + fromToken = fromToken, + toToken = toToken, + fromAccount = fromAccount, + toAccount = toAccount, + ) + ) { + return + } + modelScope.launch { + TangemLogger.i( + "updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " + + "isOrderReversed: ${isOrderReversed.value}", + ) + if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) { + uiState = stateBuilder.createInitialLoadingState( + initialCurrencyFrom = fromToken.currency, + initialCurrencyTo = toToken.currency, + fromNetworkInfo = fromToken.currency.getNetworkInfo(), + ) + } + updateFeePaidCryptoCurrencyFor(fromToken) + startLoadingQuotes( + fromToken = fromToken, + fromAccount = fromAccount, + toToken = toToken, + toAccount = toAccount, + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + toProvidersList = findSwapProviders(fromToken, toToken), + ) + } + updateTokensState(tokens) } @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -1923,8 +1921,8 @@ internal class SwapModel @Inject constructor( state: TokensDataStateExpress, fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, + fromAccount: Account?, + toAccount: Account?, ): Boolean { val selectedCurrency = if (isOrderReversed.value) fromToken else toToken if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false @@ -1972,8 +1970,12 @@ internal class SwapModel @Inject constructor( } } + private fun isTangemPayWithdrawal(): Boolean { + return tangemPayInput?.isWithdrawal == true || dataState.fromAccount is Account.Payment + } + private fun List.filterForTangemPayWithdrawal(): List { - return if (tangemPayInput?.isWithdrawal == true) { + return if (isTangemPayWithdrawal()) { filter { it.type == ExchangeProviderType.CEX } } else { this diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index f352427c56..1c031215dd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -15,8 +15,8 @@ data class SwapProcessDataState( val fromCryptoCurrency: CryptoCurrencyStatus? = null, val toCryptoCurrency: CryptoCurrencyStatus? = null, val feePaidCryptoCurrency: CryptoCurrencyStatus? = null, - val fromAccount: Account.CryptoPortfolio? = null, - val toAccount: Account.CryptoPortfolio? = null, + val fromAccount: Account? = null, + val toAccount: Account? = null, // Amount from input val amount: String? = null, val reduceBalanceBy: BigDecimal = BigDecimal.ZERO, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 6f704854d9..0ebbb39c1e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.ui import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM @@ -177,8 +178,8 @@ internal class StateBuilder( uiStateHolder: SwapStateHolder, fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, + fromAccount: Account?, + toAccount: Account?, mainTokenId: String, ): SwapStateHolder { val canSelectSendToken = mainTokenId != fromToken.currency.id.value @@ -241,8 +242,8 @@ internal class StateBuilder( fromToken: CryptoCurrency, toToken: CryptoCurrency, mainTokenId: String, - fromAccount: Account.CryptoPortfolio?, - toAccount: Account.CryptoPortfolio?, + fromAccount: Account?, + toAccount: Account?, ): SwapStateHolder { val canSelectSendToken = mainTokenId != fromToken.id.value val canSelectReceiveToken = mainTokenId != toToken.id.value @@ -501,7 +502,7 @@ internal class StateBuilder( swapProvider: SwapProvider, fromToken: TokenSwapInfo, toToken: CryptoCurrencyStatus?, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, isReverseSwapPossible: Boolean, @@ -618,7 +619,7 @@ internal class StateBuilder( emptyAmountState: SwapState.EmptyAmountState, fromTokenStatus: CryptoCurrencyStatus, toTokenStatus: CryptoCurrencyStatus?, - toAccount: Account.CryptoPortfolio?, + toAccount: Account?, isReverseSwapPossible: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder @@ -693,7 +694,7 @@ internal class StateBuilder( amountFormatted: String, amountRaw: String, fromToken: CryptoCurrency, - fromAccount: Account.CryptoPortfolio?, + fromAccount: Account?, minTxAmount: BigDecimal?, ): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState @@ -1327,30 +1328,37 @@ internal class StateBuilder( return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId) } - private fun getFromCardAccountTitle(fromAccount: Account.CryptoPortfolio?): AccountTitleUM { + private fun getFromCardAccountTitle(fromAccount: Account?): AccountTitleUM { return if (fromAccount != null && isAccountsModeProvider()) { AccountTitleUM.Account( prefixText = resourceReference(R.string.common_from), name = fromAccount.accountName.toUM().value, - icon = CryptoPortfolioIconConverter.convert(fromAccount.icon), + icon = fromAccount.toIconUM(), ) } else { AccountTitleUM.Text(resourceReference(R.string.swapping_from_title)) } } - private fun getToCardAccountTitle(toAccount: Account.CryptoPortfolio?): AccountTitleUM { + private fun getToCardAccountTitle(toAccount: Account?): AccountTitleUM { return if (toAccount != null && isAccountsModeProvider()) { AccountTitleUM.Account( prefixText = resourceReference(R.string.common_to), name = toAccount.accountName.toUM().value, - icon = CryptoPortfolioIconConverter.convert(toAccount.icon), + icon = toAccount.toIconUM(), ) } else { AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)) } } + private fun Account.toIconUM(): AccountIconUM { + return when (this) { + is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon) + is Account.Payment -> AccountIconUM.Payment + } + } + private fun getChangeCardsButtonState(isReverseSwapPossible: Boolean) = if (isReverseSwapPossible) { ChangeCardsButtonState.ENABLED } else { From f57a7ecac9b5c470067a0e7aa41a57bef1eed024 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 30 Mar 2026 15:11:36 +0400 Subject: [PATCH 006/206] Updated on 2026-08-14 --- .../com/tangem/tap/common/redux/AppState.kt | 4 - .../tap/common/redux/global/GlobalAction.kt | 6 -- .../common/redux/global/GlobalMiddleware.kt | 43 ---------- .../tap/common/redux/global/GlobalReducer.kt | 7 -- .../tap/common/redux/global/GlobalState.kt | 2 - .../common/redux/legacy/LegacyMiddleware.kt | 79 ------------------- .../features/details/redux/DetailsAction.kt | 8 -- .../details/redux/DetailsMiddleware.kt | 6 +- .../features/details/redux/DetailsReducer.kt | 16 +--- .../features/details/redux/DetailsState.kt | 3 - .../ui/appsettings/model/AppSettingsModel.kt | 6 +- .../com/tangem/domain/redux/LegacyAction.kt | 8 -- .../features/details/model/DetailsModel.kt | 10 --- 13 files changed, 6 insertions(+), 192 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt delete mode 100644 domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt index 89851458ac..a6275955a1 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt @@ -1,8 +1,6 @@ package com.tangem.tap.common.redux -import com.tangem.tap.common.redux.global.GlobalMiddleware import com.tangem.tap.common.redux.global.GlobalState -import com.tangem.tap.common.redux.legacy.LegacyMiddleware import com.tangem.tap.features.details.redux.DetailsMiddleware import com.tangem.tap.features.details.redux.DetailsState import com.tangem.tap.proxy.redux.DaggerGraphMiddleware @@ -20,12 +18,10 @@ data class AppState( fun getMiddleware(): List> { return listOf( logMiddleware, - GlobalMiddleware.handler, DetailsMiddleware().detailsMiddleware, LockUserWalletsTimerMiddleware().middleware, AccessCodeRequestPolicyMiddleware().middleware, DaggerGraphMiddleware.daggerGraphMiddleware, - LegacyMiddleware.legacyMiddleware, ) } } diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt index a36bd807b6..c446ff5206 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt @@ -1,6 +1,5 @@ package com.tangem.tap.common.redux.global -import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.scan.ScanResponse import org.rekotlin.Action @@ -8,10 +7,5 @@ sealed class GlobalAction : Action { data class SaveScanResponse(val scanResponse: ScanResponse) : GlobalAction() - data class ChangeAppCurrency(val appCurrency: AppCurrency) : GlobalAction() - object RestoreAppCurrency : GlobalAction() { - data class Success(val appCurrency: AppCurrency) : GlobalAction() - } - data class IsSignWithRing(val isSignWithRing: Boolean) : GlobalAction() } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt deleted file mode 100644 index f2df3df6e9..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalMiddleware.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.tap.common.redux.global - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Middleware - -object GlobalMiddleware { - val handler = globalMiddlewareHandler -} - -private val globalMiddlewareHandler: Middleware = { _, _ -> - { nextDispatch -> - { action -> - handleAction(action) - nextDispatch(action) - } - } -} - -private fun handleAction(action: Action) { - when (action) { - is GlobalAction.RestoreAppCurrency -> restoreAppCurrency() - } -} - -private fun restoreAppCurrency() { - scope.launch { - val currency = store.inject(DaggerGraphState::appCurrencyRepository) - .getSelectedAppCurrency() - .firstOrNull() - ?: AppCurrency.Default - - store.dispatchWithMain(GlobalAction.RestoreAppCurrency.Success(currency)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt index e46630172e..32164696dc 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt @@ -13,13 +13,6 @@ fun globalReducer(action: Action, state: AppState): GlobalState { is GlobalAction.SaveScanResponse -> { globalState.copy(scanResponse = action.scanResponse) } - is GlobalAction.ChangeAppCurrency -> { - globalState.copy(appCurrency = action.appCurrency) - } - is GlobalAction.RestoreAppCurrency.Success -> { - globalState.copy(appCurrency = action.appCurrency) - } is GlobalAction.IsSignWithRing -> globalState.copy(isLastSignWithRing = action.isSignWithRing) - else -> globalState } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt index 01782e2e9a..843d9f2977 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt @@ -1,6 +1,5 @@ package com.tangem.tap.common.redux.global -import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.scan.ScanResponse import com.tangem.tap.domain.TapWalletManager import org.rekotlin.StateType @@ -9,7 +8,6 @@ data class GlobalState( @Deprecated("Use scan response from selected user wallet") val scanResponse: ScanResponse? = null, val tapWalletManager: TapWalletManager = TapWalletManager(), - val appCurrency: AppCurrency = AppCurrency.Default, val isLastSignWithRing: Boolean = false, ) : StateType diff --git a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt deleted file mode 100644 index aee323386c..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/legacy/LegacyMiddleware.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.tangem.tap.common.redux.legacy - -import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.redux.LegacyAction -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.details.redux.AppSettingsState -import com.tangem.tap.features.details.redux.DetailsAction -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import com.tangem.tap.tangemSdkManager -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.* -import org.rekotlin.Middleware - -@Suppress("MemberNameEqualsClassName") -internal object LegacyMiddleware { - private val prepareDetailsScreenJobHolder = JobHolder() - - val legacyMiddleware: Middleware = { _, _ -> - { next -> - { action -> - when (action) { - is LegacyAction.PrepareDetailsScreen -> { - selectedUserWallet() - .distinctUntilChanged { old, new -> - if (old is UserWallet.Cold && new is UserWallet.Cold) { - old.walletId == new.walletId && - old.scanResponse == new.scanResponse - } else { - old.walletId == new.walletId - } - } - .onEach { selectedUserWallet -> - val initializedAppSettingsStateContent = initializeAppSettingsState() - store.dispatchWithMain( - DetailsAction.PrepareScreen( - scanResponse = (selectedUserWallet as? UserWallet.Cold)?.scanResponse, - initializedAppSettingsState = initializedAppSettingsStateContent, - ), - ) - } - .flowOn(Dispatchers.IO) - .launchIn(scope) - .saveIn(prepareDetailsScreenJobHolder) - } - } - next(action) - } - } - } - - private fun selectedUserWallet(): Flow { - return store.inject(DaggerGraphState::userWalletsListRepository).selectedUserWallet.filterNotNull() - } - - /** - * LEGACY: We need to initialize [AppSettingsState] async to avoid drawing blocking - * previously it was initialized in runBlocking and blocked details screen - */ - private suspend fun initializeAppSettingsState(): AppSettingsState { - return AppSettingsState( - selectedAppCurrency = store.state.globalState.appCurrency, - selectedThemeMode = store.inject(DaggerGraphState::appThemeModeRepository).getAppThemeMode().firstOrNull() - ?: AppThemeMode.DEFAULT, - requireAccessCode = store.inject(DaggerGraphState::walletsRepository).requireAccessCode(), - useBiometricAuthentication = store.inject(DaggerGraphState::walletsRepository).useBiometricAuthentication(), - isHidingEnabled = store.inject(DaggerGraphState::balanceHidingRepository) - .getBalanceHidingSettings().isHidingEnabledInSettings, - needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, - hasSecuredWallets = store.inject(DaggerGraphState::userWalletsListRepository).hasSecuredWallets(), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt index 0c647de6c4..072d738717 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt @@ -2,18 +2,12 @@ package com.tangem.tap.features.details.redux import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.models.scan.ScanResponse import kotlinx.coroutines.CoroutineScope import org.rekotlin.Action @Suppress("BooleanPropertyNaming") sealed class DetailsAction : Action { - data class PrepareScreen( - val scanResponse: ScanResponse?, - val initializedAppSettingsState: AppSettingsState, - ) : DetailsAction() - sealed class AppSettings : DetailsAction() { data class SwitchPrivacySetting( val enable: Boolean, @@ -50,6 +44,4 @@ sealed class DetailsAction : Action { data class Prepare(val state: AppSettingsState) : AppSettings() } - - data class ChangeAppCurrency(val currency: AppCurrency) : DetailsAction() } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt index 724134783f..fa438bf186 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt @@ -12,7 +12,6 @@ import com.tangem.tap.common.analytics.events.Settings import com.tangem.tap.common.extensions.dispatchWithMain import com.tangem.tap.common.extensions.inject import com.tangem.tap.common.redux.AppState -import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope @@ -78,10 +77,7 @@ class DetailsMiddleware { is DetailsAction.AppSettings.ChangeBalanceHiding -> { changeBalanceHiding(action.shouldHideBalance) } - is DetailsAction.AppSettings.ChangeAppCurrency -> { - store.dispatch(GlobalAction.ChangeAppCurrency(action.currency)) - store.dispatch(DetailsAction.ChangeAppCurrency(action.currency)) - } + is DetailsAction.AppSettings.ChangeAppCurrency, is DetailsAction.AppSettings.SwitchPrivacySetting.Success, is DetailsAction.AppSettings.SwitchPrivacySetting.Failure, is DetailsAction.AppSettings.BiometricsStatusChanged, diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt index a01428dbfd..40549d69f4 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt @@ -12,27 +12,12 @@ private fun internalReduce(action: Action, state: AppState): DetailsState { if (action !is DetailsAction) return state.detailsState val detailsState = state.detailsState return when (action) { - is DetailsAction.PrepareScreen -> { - handlePrepareScreen(action) - } is DetailsAction.AppSettings -> { handlePrivacyAction(action, detailsState) } - is DetailsAction.ChangeAppCurrency -> detailsState.copy( - appSettingsState = detailsState.appSettingsState.copy( - selectedAppCurrency = action.currency, - ), - ) } } -private fun handlePrepareScreen(action: DetailsAction.PrepareScreen): DetailsState { - return DetailsState( - scanResponse = action.scanResponse, - appSettingsState = action.initializedAppSettingsState, - ) -} - @Suppress("LongMethod", "CyclomaticComplexMethod") private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: DetailsState): DetailsState { return when (action) { @@ -94,6 +79,7 @@ private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: Detail useBiometricAuthentication = action.state.useBiometricAuthentication, requireAccessCode = action.state.requireAccessCode, hasSecuredWallets = action.state.hasSecuredWallets, + needEnrollBiometrics = action.state.needEnrollBiometrics, ), ) is DetailsAction.AppSettings.EnrollBiometrics, diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt index 83cf685304..e209c707e4 100644 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt +++ b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt @@ -2,12 +2,9 @@ package com.tangem.tap.features.details.redux import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.models.scan.ScanResponse import org.rekotlin.StateType data class DetailsState( - @Deprecated("Delete after onboarding refactoring") - val scanResponse: ScanResponse? = null, val appSettingsState: AppSettingsState = AppSettingsState(), ) : StateType diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt index 3f7613c86c..e65c6560da 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt @@ -14,6 +14,7 @@ import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.apptheme.repository.AppThemeModeRepository import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.sdk.api.TangemSdkManager import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings @@ -54,6 +55,7 @@ internal class AppSettingsModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val appThemeModeRepository: AppThemeModeRepository, private val appSettingsItemsAnalyticsSender: AppSettingsItemsAnalyticsSender, + private val tangemSdkManager: TangemSdkManager, private val uiMessageSender: UiMessageSender, ) : Model(), StoreSubscriber { @@ -251,9 +253,8 @@ internal class AppSettingsModel @Inject constructor( private fun bootstrapAppCurrencyUpdates() { appCurrencyRepository .getSelectedAppCurrency() + .distinctUntilChanged() .onEach { appCurrency -> - if (appCurrency.code == store.state.globalState.appCurrency.code) return@onEach - store.dispatchWithMain(DetailsAction.AppSettings.ChangeAppCurrency(appCurrency)) } .launchIn(scope) @@ -267,6 +268,7 @@ internal class AppSettingsModel @Inject constructor( isHidingEnabled = balanceHidingRepository.getBalanceHidingSettings().isHidingEnabledInSettings, selectedAppCurrency = appCurrencyRepository.getSelectedAppCurrency().firstOrNull() ?: AppCurrency.Default, selectedThemeMode = appThemeModeRepository.getAppThemeMode().firstOrNull() ?: AppThemeMode.DEFAULT, + needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(), ) diff --git a/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt b/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt deleted file mode 100644 index a9d9660b18..0000000000 --- a/domain/legacy/src/main/java/com/tangem/domain/redux/LegacyAction.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.domain.redux - -import org.rekotlin.Action - -sealed interface LegacyAction : Action { - - data object PrepareDetailsScreen : LegacyAction -} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index a4d4c58b0a..e501fd3751 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -22,8 +22,6 @@ import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.model.TangemPayEntryPoint -import com.tangem.domain.redux.LegacyAction -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase @@ -61,7 +59,6 @@ internal class DetailsModel @Inject constructor( private val router: Router, private val urlOpener: UrlOpener, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, - private val appStateHolder: ReduxStateHolder, private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, @@ -79,9 +76,6 @@ internal class DetailsModel @Inject constructor( val state: MutableStateFlow init { - // Use to save compatibility with screens that using Redux states - bootstrapScreenState() - val isWalletConnectAvailable = runBlocking { // danger region, this works immediately, but will be refactored later with WC checkIsWalletConnectAvailableUseCase(params.userWalletId).getOrElse { throwable -> @@ -122,10 +116,6 @@ internal class DetailsModel @Inject constructor( .launchIn(modelScope) } - private fun bootstrapScreenState() { - appStateHolder.dispatch(LegacyAction.PrepareDetailsScreen) - } - private fun sendFeedback() { modelScope.launch { val userWallets = getWalletsUseCase.invokeSync() From f6b0bf8dd57d06e5d1f02f73b524bf13da366c46 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Apr 2026 19:51:04 +0400 Subject: [PATCH 007/206] Updated on 2026-08-14 --- .../NavigationButtonsState.kt | 2 ++ .../di/StakingBalanceSupplierModule.kt | 10 ++++---- domain/staking/detekt-baseline-debug.xml | 13 ---------- .../FetchStakingYieldBalanceUseCase.kt | 6 ++--- ...GetConstructedStakingTransactionUseCase.kt | 7 +++++- .../InvalidatePendingTransactionsUseCase.kt | 10 ++++---- .../analytics/StakingAnalyticsEvent.kt | 2 +- .../multi/MultiStakingBalanceSupplier.kt | 2 +- .../single/SingleStakingBalanceSupplier.kt | 2 +- .../staking/impl/detekt-baseline-debug.xml | 25 ------------------- .../deeplink/DefaultStakingDeepLinkHandler.kt | 10 ++++---- .../presentation/model/StakingClickIntents.kt | 2 ++ .../state/StakingStateController.kt | 8 +++--- .../impl/presentation/state/StakingUiState.kt | 19 +++----------- .../previewdata/InitialStakingStatePreview.kt | 2 +- .../SetButtonsStateTransformer.kt | 4 +-- .../SetInitialDataStateTransformer.kt | 2 +- .../AmountCurrencyChangeStateTransformer.kt | 4 +-- ...pprovalBottomSheetInProgressTransformer.kt | 2 +- ...pprovalBottomSheetTypeChangeTransformer.kt | 2 +- .../AddStakingNotificationsTransformer.kt | 16 ++++++------ .../StakingInfoNotificationsFactory.kt | 8 +++--- .../ui/StakingInitialInfoContent.kt | 24 ++++++++---------- .../presentation/ui/block/StakingFeeBlock.kt | 22 ++++++++-------- 24 files changed, 80 insertions(+), 124 deletions(-) delete mode 100644 domain/staking/detekt-baseline-debug.xml delete mode 100644 features/staking/impl/detekt-baseline-debug.xml diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt index 8b1bebbcd4..182696bedf 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationButtonsState.kt @@ -1,8 +1,10 @@ package com.tangem.common.ui.navigationButtons import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable import com.tangem.core.ui.extensions.TextReference +@Immutable sealed class NavigationButtonsState { data object Empty : NavigationButtonsState() diff --git a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt index 4ddab5988c..8937c4ec40 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/di/StakingBalanceSupplierModule.kt @@ -8,11 +8,11 @@ import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.staking.multi.MultiStakingBalanceProducer import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier import com.tangem.domain.staking.single.SingleStakingBalanceProducer import com.tangem.domain.staking.single.SingleStakingBalanceSupplier +import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -54,7 +54,7 @@ internal object StakingBalanceSupplierModule { fun provideSingleStakingBalanceSupplier( factory: SingleStakingBalanceProducer.Factory, ): SingleStakingBalanceSupplier { - return object : SingleStakingBalanceSupplier( + return SingleStakingBalanceSupplier( factory = factory, keyCreator = { params -> listOf( @@ -65,15 +65,15 @@ internal object StakingBalanceSupplierModule { ) .joinToString(separator = "_") }, - ) {} + ) } @Provides @Singleton fun provideMultiStakingBalanceSupplier(factory: MultiStakingBalanceProducer.Factory): MultiStakingBalanceSupplier { - return object : MultiStakingBalanceSupplier( + return MultiStakingBalanceSupplier( factory = factory, keyCreator = { "multi_staking_balances_${it.userWalletId.stringValue}" }, - ) {} + ) } } \ No newline at end of file diff --git a/domain/staking/detekt-baseline-debug.xml b/domain/staking/detekt-baseline-debug.xml deleted file mode 100644 index f52428665c..0000000000 --- a/domain/staking/detekt-baseline-debug.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - MultilineLambdaItParameter:FetchStakingYieldBalanceUseCase.kt$FetchStakingYieldBalanceUseCase${ when (it) { is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } return@either } - MultilineLambdaItParameter:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase${ !it.isPending && action.amount < it.amount && it.type == BalanceType.STAKED && it.validatorAddress == action.validatorAddress } - NamedArguments:GetConstructedStakingTransactionUseCase.kt$GetConstructedStakingTransactionUseCase$constructTransaction(networkId, fee, amount, transactionId) - UnnecessaryAbstractClass:MultiStakingBalanceSupplier.kt$MultiStakingBalanceSupplier$MultiStakingBalanceSupplier - UnnecessaryAbstractClass:SingleStakingBalanceSupplier.kt$SingleStakingBalanceSupplier$SingleStakingBalanceSupplier - UseEmptyCounterpart:StakingAnalyticsEvent.kt$StakingAnalyticsEvent$mapOf() - UseOrEmpty:InvalidatePendingTransactionsUseCase.kt$InvalidatePendingTransactionsUseCase$action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "" - - diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt index a438d88cef..2e4836733e 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/FetchStakingYieldBalanceUseCase.kt @@ -23,9 +23,9 @@ class FetchStakingYieldBalanceUseCase( currencyId = cryptoCurrency.id, network = cryptoCurrency.network, ) - .getOrElse { - when (it) { - is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$it")) + .getOrElse { error -> + when (error) { + is StakingIdFactory.Error.UnableToGetAddress -> raise(StakingError.DomainError("$error")) StakingIdFactory.Error.UnsupportedCurrency -> Unit.right() } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt index 3402b6bb03..7242d70f51 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt @@ -20,7 +20,12 @@ class GetConstructedStakingTransactionUseCase( amount: Amount, transactionId: String, ): Either> = Either.catch { - stakeKitRepository.constructTransaction(networkId, fee, amount, transactionId) + stakeKitRepository.constructTransaction( + networkId = networkId, + fee = fee, + amount = amount, + transactionId = transactionId, + ) }.mapLeft { stakingErrorResolver.resolve(it) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt index f24cac1f74..a58ad4c346 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/InvalidatePendingTransactionsUseCase.kt @@ -100,7 +100,7 @@ class InvalidatePendingTransactionsUseCase( type = BalanceType.STAKED, amount = action.amount, rawCurrencyId = null, - validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0) ?: "", + validatorAddress = action.validatorAddress ?: action.validatorAddresses?.getOrNull(0).orEmpty(), date = null, pendingActions = emptyList(), pendingActionsConstraints = emptyList(), @@ -149,10 +149,10 @@ class InvalidatePendingTransactionsUseCase( } private fun findPartialUnstake(balances: MutableList, action: StakingAction): Pair { - val index = balances.indexOfFirst { - !it.isPending && action.amount < it.amount && - it.type == BalanceType.STAKED && - it.validatorAddress == action.validatorAddress + val index = balances.indexOfFirst { balance -> + !balance.isPending && action.amount < balance.amount && + balance.type == BalanceType.STAKED && + balance.validatorAddress == action.validatorAddress } return index to action.amount } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt index cdf1a16bad..3b67c72c6f 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.staking.action.StakingActionType sealed class StakingAnalyticsEvent( event: String, - params: Map = mapOf(), + params: Map = emptyMap(), ) : AnalyticsEvent( category = "Staking", event = event, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt index 106e390f01..7932714729 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/multi/MultiStakingBalanceSupplier.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance * [REDACTED_AUTHOR] */ -abstract class MultiStakingBalanceSupplier( +open class MultiStakingBalanceSupplier( override val factory: FlowProducer.Factory, override val keyCreator: (MultiStakingBalanceProducer.Params) -> String, ) : FlowCachingSupplier>() \ No newline at end of file diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt index 9474e0e172..a9050acd21 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/single/SingleStakingBalanceSupplier.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.staking.StakingBalance * [REDACTED_AUTHOR] */ -abstract class SingleStakingBalanceSupplier( +open class SingleStakingBalanceSupplier( override val factory: FlowProducer.Factory, override val keyCreator: (SingleStakingBalanceProducer.Params) -> String, ) : FlowCachingSupplier() \ No newline at end of file diff --git a/features/staking/impl/detekt-baseline-debug.xml b/features/staking/impl/detekt-baseline-debug.xml deleted file mode 100644 index 8cd2dca55b..0000000000 --- a/features/staking/impl/detekt-baseline-debug.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - BooleanPropertyNaming:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer$val showNotification = sendingAmount + feeAmount > balance - BooleanPropertyNaming:AmountCurrencyChangeStateTransformer.kt$AmountCurrencyChangeStateTransformer$private val value: Boolean - BooleanPropertyNaming:StakingUiState.kt$StakingStates.InitialInfoState.Data$val showBanner: Boolean - BooleanPropertyNaming:StakingUiState.kt$StakingUiState$val showColdWalletInteractionIcon: Boolean - CastNullableToNonNullableType:SetApprovalBottomSheetInProgressTransformer.kt$SetApprovalBottomSheetInProgressTransformer$as - CastNullableToNonNullableType:SetApprovalBottomSheetTypeChangeTransformer.kt$SetApprovalBottomSheetTypeChangeTransformer$as - MultilineLambdaItParameter:AddStakingNotificationsTransformer.kt$AddStakingNotificationsTransformer${ it is StakingNotification.Error || it is NotificationUM.Error || it is NotificationUM.Warning.NetworkFeeUnreachable || it is StakingNotification.Warning.TransactionInProgress || it is StakingNotification.Warning.InitializeTonAccount } - MultilineLambdaItParameter:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler${ val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } - MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, style = TangemTheme.typography.body1, ) } } - MultilineLambdaItParameter:StakingFeeBlock.kt${ if (it == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( height = TangemTheme.dimens.size24, width = TangemTheme.dimens.size90, ), ) } } - MultilineLambdaItParameter:StakingInfoNotificationsFactory.kt$StakingInfoNotificationsFactory${ it.type == BalanceType.PREPARING || it.type == BalanceType.STAKED || it.type == BalanceType.LOCKED } - MultilineLambdaItParameter:StakingStateController.kt$StakingStateController${ it.copy( showColdWalletInteractionIcon = userWallet is UserWallet.Cold, ) } - NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$networkId - NullableToStringCall:DefaultStakingDeepLinkHandler.kt$DefaultStakingDeepLinkHandler$$tokenId - PropertyUsedBeforeDeclaration:StakingFeeBlock.kt$FeeBlockPreviewProvider$contentState - PropertyUsedBeforeDeclaration:StakingStateController.kt$StakingStateController$uiState - UnnecessaryEventHandlerParameter:StakingInitialInfoContent.kt$onClick: (BalanceState) -> Unit - UnnecessaryLet:StakingTosText.kt$let { onTextClick(PRIVACY_POLICY_URL) } - UnnecessaryLet:StakingTosText.kt$let { onTextClick(TERMS_OF_USE_URL) } - - diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index c861dfb887..a12c16b790 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -53,9 +53,9 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( params = MultiWalletCryptoCurrenciesProducer.Params(selectedUserWalletId), ) .orEmpty() - .firstOrNull { - val isNetwork = it.network.backendId.equals(networkId, ignoreCase = true) - val isCurrency = it.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true + .firstOrNull { currency -> + val isNetwork = currency.network.backendId.equals(networkId, ignoreCase = true) + val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } @@ -63,8 +63,8 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( TangemLogger.e( """ Could not get crypto currency for - |- $NETWORK_ID_KEY: $networkId - |- $TOKEN_ID_KEY: $tokenId + |- $NETWORK_ID_KEY: ${networkId.orEmpty()} + |- $TOKEN_ID_KEY: ${tokenId.orEmpty()} """.trimIndent(), ) return@launch diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt index 66b3ccedc6..9ae89b73e3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt @@ -1,5 +1,6 @@ package com.tangem.features.staking.impl.presentation.model +import androidx.compose.runtime.Immutable import com.tangem.common.ui.amountScreen.AmountScreenClickIntents import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.notifications.NotificationUM @@ -11,6 +12,7 @@ import java.math.BigDecimal // TODO split this interface to click intents and other interaction events @Suppress("TooManyFunctions") +@Immutable internal interface StakingClickIntents : AmountScreenClickIntents { fun onBackClick() diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index 7a02bd36ef..32844ee371 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -26,19 +26,19 @@ internal class StakingStateController @Inject constructor( private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) { - val value: StakingUiState get() = uiState.value - private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) val uiState: StateFlow get() = mutableUiState.asStateFlow() + val value: StakingUiState get() = uiState.value + private val buttonsTransformer = SetButtonsStateTransformer(urlOpener) private val titleTransformer = SetTitleTransformer fun initializeWithUserWallet(userWallet: UserWallet) { mutableUiState.update { state -> state.copy( - showColdWalletInteractionIcon = userWallet.isColdWallet, + isColdWalletInteractionIconVisible = userWallet.isColdWallet, shouldShowHoldToConfirmButton = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && userWallet.isHotWallet, ) @@ -89,7 +89,7 @@ internal class StakingStateController @Inject constructor( actionType = StakingActionCommonType.Enter(skipEnterAmount = false), buttonsState = NavigationButtonsState.Empty, balanceState = null, - showColdWalletInteractionIcon = true, + isColdWalletInteractionIconVisible = true, shouldShowHoldToConfirmButton = false, ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt index 8624ce9a72..f9a8b1cf23 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingUiState.kt @@ -39,22 +39,9 @@ internal data class StakingUiState( val actionType: StakingActionCommonType, val buttonsState: NavigationButtonsState, val balanceState: BalanceState?, - val showColdWalletInteractionIcon: Boolean, + val isColdWalletInteractionIconVisible: Boolean, val shouldShowHoldToConfirmButton: Boolean, -) { - - fun copyWrapped( - initialInfoState: StakingStates.InitialInfoState = this.initialInfoState, - amountState: AmountState = this.amountState, - confirmationState: StakingStates.ConfirmationState = this.confirmationState, - validatorState: StakingStates.ValidatorState = this.validatorState, - ): StakingUiState = copy( - initialInfoState = initialInfoState, - amountState = amountState, - confirmationState = confirmationState, - validatorState = validatorState, - ) -} +) internal sealed class StakingStates { @@ -64,7 +51,7 @@ internal sealed class StakingStates { sealed class InitialInfoState : StakingStates() { data class Data( override val isPrimaryButtonEnabled: Boolean, - val showBanner: Boolean, + val isBannerVisible: Boolean, val infoItems: ImmutableList, val onInfoClick: (InfoType) -> Unit, val yieldBalance: InnerYieldBalanceState, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt index c54d001d68..ba083ad159 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/previewdata/InitialStakingStatePreview.kt @@ -18,7 +18,7 @@ import kotlinx.collections.immutable.persistentListOf internal object InitialStakingStatePreview { val defaultState = StakingStates.InitialInfoState.Data( isPrimaryButtonEnabled = true, - showBanner = true, + isBannerVisible = true, infoItems = persistentListOf( RoundedListWithDividersItemData( id = R.string.staking_details_available, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt index 725c5869e8..b67b009796 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetButtonsStateTransformer.kt @@ -37,7 +37,7 @@ internal class SetButtonsStateTransformer( return prevState.copy(buttonsState = buttonsState) } - private fun getPrimaryButton(prevState: StakingUiState): NavigationButton? { + private fun getPrimaryButton(prevState: StakingUiState): NavigationButton { val confirmState = prevState.confirmationState as? StakingStates.ConfirmationState.Data val innerConfirmState = confirmState?.innerState @@ -52,7 +52,7 @@ internal class SetButtonsStateTransformer( val isPrimaryButtonDisabled = prevState.isPrimaryButtonDisabled() return NavigationButton( textReference = prevState.getButtonText(), - iconRes = R.drawable.ic_tangem_24.takeIf { prevState.showColdWalletInteractionIcon }, + iconRes = R.drawable.ic_tangem_24.takeIf { prevState.isColdWalletInteractionIconVisible }, isDimmed = isPrimaryButtonDisabled, isIconVisible = isIconVisible, shouldShowProgress = isInProgress, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index 1dbb49e096..c6c30d4bcb 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -96,7 +96,7 @@ internal class SetInitialDataStateTransformer( isPrimaryButtonEnabled = with(status) { !amount.isNullOrZero() && sources.stakingBalanceSource.isActual() && sources.networkSource.isActual() }, - showBanner = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty, + isBannerVisible = !isAnyTokenStaked && yieldBalance == InnerYieldBalanceState.Empty, infoItems = getInfoItems(), onInfoClick = clickIntents::onInfoClick, yieldBalance = yieldBalance, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt index 9631543416..5c121f20e1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/amount/AmountCurrencyChangeStateTransformer.kt @@ -7,11 +7,11 @@ import com.tangem.utils.transformer.Transformer internal class AmountCurrencyChangeStateTransformer( private val cryptoCurrencyStatus: CryptoCurrencyStatus, - private val value: Boolean, + private val isFiatValue: Boolean, ) : Transformer { override fun transform(prevState: StakingUiState): StakingUiState { return prevState.copy( - amountState = AmountCurrencyTransformer(cryptoCurrencyStatus, value).transform(prevState.amountState), + amountState = AmountCurrencyTransformer(cryptoCurrencyStatus, isFiatValue).transform(prevState.amountState), ) } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt index 5221aba998..997a7e6743 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt @@ -27,7 +27,7 @@ internal class SetApprovalBottomSheetInProgressTransformer( ), onCancel = onDismiss, ) - } as TangemBottomSheetConfigContent, + } as? TangemBottomSheetConfigContent ?: return prevState, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt index 0e58d0c7eb..099b77c51a 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt @@ -16,7 +16,7 @@ internal class SetApprovalBottomSheetTypeChangeTransformer( bottomSheetConfig = prevState.bottomSheetConfig?.copy( content = approvalBottomSheetConfig?.copy( data = approvalBottomSheetConfig.data.copy(approveType = approveType), - ) as TangemBottomSheetConfigContent, + ) as? TangemBottomSheetConfigContent ?: return prevState, ), ) } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt index 7bb3f0630d..8849435172 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt @@ -177,12 +177,12 @@ internal class AddStakingNotificationsTransformer( } private fun isPrimaryButtonEnabled(notifications: ImmutableList, isActualSources: Boolean) = - notifications.none { - it is StakingNotification.Error || - it is NotificationUM.Error || - it is NotificationUM.Warning.NetworkFeeUnreachable || - it is StakingNotification.Warning.TransactionInProgress || - it is StakingNotification.Warning.InitializeTonAccount + notifications.none { notification -> + notification is StakingNotification.Error || + notification is NotificationUM.Error || + notification is NotificationUM.Warning.NetworkFeeUnreachable || + notification is StakingNotification.Warning.TransactionInProgress || + notification is StakingNotification.Warning.InitializeTonAccount } && isActualSources private fun MutableList.addStakingErrorNotifications( @@ -302,8 +302,8 @@ internal class AddStakingNotificationsTransformer( val balance = cryptoCurrencyStatus.value.amount.orZero() if (!isSubtractionAvailable) return - val showNotification = sendingAmount + feeAmount > balance - if (showNotification) { + val isExceedsBalance = sendingAmount + feeAmount > balance + if (isExceedsBalance) { onNotEnoughFeeNotificationShow() val notification = if (actionType is StakingActionCommonType.Enter) { NotificationUM.Error.TotalExceedsBalance diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt index 8628070ef4..02a41fe4a3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/StakingInfoNotificationsFactory.kt @@ -150,10 +150,10 @@ internal class StakingInfoNotificationsFactory( val cryptoCurrencyStatus = cryptoCurrencyStatusProvider() val isTron = isTron(cryptoCurrencyStatus.currency.network.rawId) val hasStakedBalance = (cryptoCurrencyStatus.value.stakingBalance as? StakingBalance.Data.StakeKit)?.balance - ?.items?.any { - it.type == BalanceType.PREPARING || - it.type == BalanceType.STAKED || - it.type == BalanceType.LOCKED + ?.items?.any { item -> + item.type == BalanceType.PREPARING || + item.type == BalanceType.STAKED || + item.type == BalanceType.LOCKED } == true if (isTron && hasStakedBalance) { add( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt index eb514ea0ba..66cb40cb89 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingInitialInfoContent.kt @@ -87,7 +87,7 @@ internal fun StakingInitialInfoContent( .background(TangemTheme.colors.background.secondary) .padding(horizontal = TangemTheme.dimens.spacing16), ) { - if (state.showBanner) { + if (state.isBannerVisible) { item(key = BANNER_BLOCK_KEY) { Column( modifier = Modifier.animateItem(), @@ -175,7 +175,7 @@ private fun LazyListScope.activeStakingBlock( ActiveStakingBlock( balance = balance, isBalanceHidden = isBalanceHidden, - onClick = clickIntents::onActiveStake, + onClick = { clickIntents.onActiveStake(balance) }, onAnalytic = clickIntents::onActiveStakeAnalytic, modifier = Modifier .animateItem() @@ -288,7 +288,7 @@ private fun StakingRewardBlock( private fun ActiveStakingBlock( balance: BalanceState, isBalanceHidden: Boolean, - onClick: (BalanceState) -> Unit, + onClick: () -> Unit, onAnalytic: () -> Unit, modifier: Modifier = Modifier, ) { @@ -304,7 +304,7 @@ private fun ActiveStakingBlock( enabled = balance.isClickable, onClick = { onAnalytic() - onClick(balance) + onClick() }, ) .padding(TangemTheme.dimens.spacing12), @@ -351,20 +351,18 @@ private fun ActiveStakingBlock( style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, ) - if (balance.formattedCryptoAmount != null) { - Text( - text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), - ) - } + Text( + text = balance.formattedCryptoAmount.orMaskWithStars(isBalanceHidden).resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(top = TangemTheme.dimens.spacing2), + ) } } } @Composable -private fun RowScope.StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) { +private fun StakingBalanceIcon(balance: BalanceState, icon: Int?, iconTint: Color) { if (balance.hasImage() || icon != null) { StakingTargetIcon( image = if (balance.hasImage()) balance.target?.image.toImageReference() else null, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt index 60ba58a048..8ad250c6d1 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/block/StakingFeeBlock.kt @@ -109,8 +109,8 @@ private fun BoxScope.FeeLoading(feeState: FeeState) { targetState = feeState, label = "Fee Loading State Change", modifier = Modifier.align(Alignment.CenterEnd), - ) { - if (it == FeeState.Loading) { + ) { state -> + if (state == FeeState.Loading) { RectangleShimmer( radius = TangemTheme.dimens.radius3, modifier = Modifier.size( @@ -128,8 +128,8 @@ private fun BoxScope.FeeError(feeState: FeeState) { targetState = feeState, label = "Fee Error State Change", modifier = Modifier.align(Alignment.CenterEnd), - ) { - if (it == FeeState.Error) { + ) { state -> + if (state == FeeState.Error) { Text( text = DASH_SIGN, color = TangemTheme.colors.text.primary1, @@ -151,13 +151,6 @@ private fun FeeBlockPreview(@PreviewParameter(FeeBlockPreviewProvider::class) va private class FeeBlockPreviewProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - contentState, - FeeState.Loading, - FeeState.Error, - ) - private val fee = Fee.Common( amount = Amount( currencySymbol = "MATIC", @@ -174,6 +167,13 @@ private class FeeBlockPreviewProvider : PreviewParameterProvider { isFeeApproximate = false, isFeeConvertibleToFiat = true, ) + + override val values: Sequence + get() = sequenceOf( + contentState, + FeeState.Loading, + FeeState.Error, + ) } // endregion \ No newline at end of file From 0b52308ff130ff045b1c3705298b75e90217497e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Apr 2026 22:03:44 +0300 Subject: [PATCH 008/206] Updated on 2026-08-14 --- .../storybook/entity/StoryBookPage.kt | 21 +- .../storybook/page/badge/TangemBadgeStory.kt | 27 +- .../storybook/page/buttons/ButtonsStory.kt | 140 ++++++++-- .../storybook/page/deviceicon/Build.kt | 9 + .../page/deviceicon/DeviceIconStory.kt | 184 +++++++++++++ .../page/headerrow/TangemHeaderRowStory.kt | 23 ++ .../storybook/page/pagerindicator/Build.kt | 9 + .../TangemPagerIndicatorStory.kt | 142 ++++++++++ .../storybook/page/placeholder/Build.kt | 9 + .../page/placeholder/PlaceholderStory.kt | 254 +++++++++++++++++ .../storybook/page/progress/Build.kt | 9 + .../page/progress/ProgressIndicatorStory.kt | 132 +++++++++ .../presentation/storybook/page/tab/Build.kt | 19 ++ .../storybook/page/tab/TangemTabStory.kt | 127 +++++++++ .../storybook/page/topbar/Build.kt | 20 ++ .../page/topbar/TangemTopBarStory.kt | 259 ++++++++++++++++++ .../storybook/ui/StoryBookListScreen.kt | 12 + .../storybook/ui/StoryBookScreen.kt | 19 ++ 18 files changed, 1394 insertions(+), 21 deletions(-) create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/TangemTabStory.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/TangemTopBarStory.kt diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index bf95156d7d..5bd49223ea 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tester.presentation.storybook.entity import com.tangem.core.ui.ds.badge.TangemBadgeColor import com.tangem.core.ui.ds.field.search.TangemFieldShape import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.topbar.TangemTopBarType internal sealed interface StoryBookPage @@ -64,4 +65,22 @@ internal data class TangemSearchFieldStory( internal data class TypographyStory( val isFontScaleDefault: Boolean, val onFontScaleToggle: () -> Unit, -) : StoryBookPage \ No newline at end of file +) : StoryBookPage + +internal data class TangemTopBarStory( + val selectedType: TangemTopBarType, + val onTypeChange: (TangemTopBarType) -> Unit, +) : StoryBookPage + +internal data class TangemTabStory( + val checkedIndex: Int, + val onCheckedIndexChange: (Int) -> Unit, +) : StoryBookPage + +internal data object TangemPagerIndicatorStory : StoryBookPage + +internal data object PlaceholderStory : StoryBookPage + +internal data object ProgressIndicatorStory : StoryBookPage + +internal data object DeviceIconStory : StoryBookPage \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt index c3c6cea2a1..c158b5f7d1 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/badge/TangemBadgeStory.kt @@ -139,12 +139,18 @@ private fun BadgeShapeGroup(size: TangemBadgeSize, shape: TangemBadgeShape, colo @Composable private fun ColumnHeaderRow() { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Spacer(Modifier.width(STATE_LABEL_WIDTH.dp)) Text( - text = "Text + Icon", + text = "Icon Start", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.weight(1f), + ) + Text( + text = "Icon End", style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.weight(1f), @@ -172,7 +178,7 @@ private fun BadgeTypeRow( type: TangemBadgeType, ) { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -196,6 +202,21 @@ private fun BadgeTypeRow( onClick = {}, ) } + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.weight(1f), + ) { + TangemBadge( + text = stringReference("New"), + tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_information_24), + size = size, + shape = shape, + color = color, + type = type, + iconPosition = TangemBadgeIconPosition.End, + onClick = {}, + ) + } Box( contentAlignment = Alignment.CenterStart, modifier = Modifier.weight(1f), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt index d1bd85a0e6..f4c2e96d65 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/buttons/ButtonsStory.kt @@ -32,7 +32,7 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { .background(TangemTheme.colors2.surface.level1), ) { item("primary") { - ButtonSection(title = "Primary") { isEnabled, text, shape -> + ButtonSection(title = "Primary") { isEnabled, isLoading, text, shape, iconPosition -> PrimaryTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -46,14 +46,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("secondary") { - ButtonSection(title = "Secondary") { isEnabled, text, shape -> + ButtonSection(title = "Secondary") { isEnabled, isLoading, text, shape, iconPosition -> SecondaryTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -67,8 +69,10 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } @@ -77,7 +81,7 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { ButtonSection( title = "PrimaryInverse", background = TangemTheme.colors2.surface.level2, - ) { isEnabled, text, shape -> + ) { isEnabled, isLoading, text, shape, iconPosition -> PrimaryInverseTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -91,14 +95,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("outline") { - ButtonSection(title = "Outline") { isEnabled, text, shape -> + ButtonSection(title = "Outline") { isEnabled, isLoading, text, shape, iconPosition -> OutlineTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -112,14 +118,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("accent") { - ButtonSection(title = "Accent") { isEnabled, text, shape -> + ButtonSection(title = "Accent") { isEnabled, isLoading, text, shape, iconPosition -> StatusTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -133,14 +141,16 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("positive") { - ButtonSection(title = "Positive") { isEnabled, text, shape -> + ButtonSection(title = "Positive") { isEnabled, isLoading, text, shape, iconPosition -> StatusTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -154,15 +164,17 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, type = TangemButtonType.Positive, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } item("ghost") { - ButtonSection(title = "Ghost") { isEnabled, text, shape -> + ButtonSection(title = "Ghost") { isEnabled, isLoading, text, shape, iconPosition -> GhostTangemButton( onClick = {}, text = if (text) stringReference("Continue") else null, @@ -176,21 +188,74 @@ internal fun ButtonsStory(modifier: Modifier = Modifier) { } }, ), + iconPosition = iconPosition, size = TangemButtonSize.X10, isEnabled = isEnabled, + isLoading = isLoading, shape = shape, ) } } + item("sizes") { + SizeShowcase() + } } } +@Composable +private fun SizeShowcase() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = "Sizes", + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + TangemButtonSize.entries.forEach { size -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = size.name, + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.width(STATE_LABEL_WIDTH.dp), + ) + PrimaryTangemButton( + onClick = {}, + text = stringReference("Button"), + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_tangem_24, + tintReference = { TangemTheme.colors2.graphic.neutral.primaryInverted }, + ), + size = size, + ) + } + } + } + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + @Composable private fun ButtonSection( title: String, background: Color = TangemTheme.colors2.surface.level1, shapes: List = TangemButtonShape.entries, - button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, + button: @Composable ( + isEnabled: Boolean, + isLoading: Boolean, + text: Boolean, + shape: TangemButtonShape, + iconPosition: TangemButtonIconPosition, + ) -> Unit, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -205,7 +270,9 @@ private fun ButtonSection( color = TangemTheme.colors.text.primary1, ) shapes.forEach { shape -> - ShapeGroup(shape = shape, button = button) + TangemButtonIconPosition.entries.forEach { iconPosition -> + ShapeGroup(shape = shape, iconPosition = iconPosition, button = button) + } } } HorizontalDivider( @@ -217,17 +284,46 @@ private fun ButtonSection( @Composable private fun ShapeGroup( shape: TangemButtonShape, - button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, + iconPosition: TangemButtonIconPosition, + button: @Composable ( + isEnabled: Boolean, + isLoading: Boolean, + text: Boolean, + shape: TangemButtonShape, + iconPosition: TangemButtonIconPosition, + ) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = shape.name, + text = "${shape.name} / Icon ${iconPosition.name}", style = TangemTheme.typography.body2, color = TangemTheme.colors.text.secondary, ) ColumnHeaderRow() - StateRow(isEnabled = true, shape = shape, button = button) - StateRow(isEnabled = false, shape = shape, button = button) + StateRow( + label = "Enabled", + isEnabled = true, + isLoading = false, + shape = shape, + iconPosition = iconPosition, + button = button, + ) + StateRow( + label = "Disabled", + isEnabled = false, + isLoading = false, + shape = shape, + iconPosition = iconPosition, + button = button, + ) + StateRow( + label = "Loading", + isEnabled = true, + isLoading = true, + shape = shape, + iconPosition = iconPosition, + button = button, + ) } } @@ -253,27 +349,37 @@ private fun ColumnHeaderRow() { } } +@Suppress("LongParameterList") @Composable private fun StateRow( + label: String, isEnabled: Boolean, + isLoading: Boolean, shape: TangemButtonShape, - button: @Composable (isEnabled: Boolean, text: Boolean, shape: TangemButtonShape) -> Unit, + iconPosition: TangemButtonIconPosition, + button: @Composable ( + isEnabled: Boolean, + isLoading: Boolean, + text: Boolean, + shape: TangemButtonShape, + iconPosition: TangemButtonIconPosition, + ) -> Unit, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (isEnabled) "Enabled" else "Disabled", + text = label, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.width(STATE_LABEL_WIDTH.dp), ) Box(modifier = Modifier.weight(1f)) { - button(isEnabled, true, shape) + button(isEnabled, isLoading, true, shape, iconPosition) } Box(modifier = Modifier.weight(1f)) { - button(isEnabled, false, shape) + button(isEnabled, isLoading, false, shape, iconPosition) } } } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt new file mode 100644 index 0000000000..aaa3b8fc51 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.deviceicon + +import com.tangem.feature.tester.presentation.storybook.entity.DeviceIconStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory + +internal val deviceIconStoryFactory: StoryPageFactory = + StoryPageFactory { DeviceIconStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt new file mode 100644 index 0000000000..0c3708b35d --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/deviceicon/DeviceIconStory.kt @@ -0,0 +1,184 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.deviceicon + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +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.Color +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.image.TangemDeviceIcon +import com.tangem.core.ui.res.TangemTheme + +private val CardBlue = Color(0xFF1C5FBF) +private val CardGold = Color(0xFFD4A017) +private val CardPurple = Color(0xFF7B2FBE) +private val RingGreen = Color(0xFF2ECC71) + +@Composable +internal fun DeviceIconStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_cards") { + SectionTitle(text = "Cards") + } + + item("card_1") { + DeviceIconRow( + label = "Single card", + state = DeviceIconUM.Card(mainColor = CardBlue, secondColor = null), + ) + } + + item("card_2") { + DeviceIconRow( + label = "Two cards", + state = DeviceIconUM.Card(mainColor = CardBlue, secondColor = CardGold), + ) + } + + item("card_3") { + DeviceIconRow( + label = "Three cards", + state = DeviceIconUM.Card( + mainColor = CardBlue, + secondColor = CardGold, + thirdColor = CardPurple, + ), + ) + } + + item("section_rings") { + SectionTitle(text = "Rings") + } + + item("ring_solo") { + DeviceIconRow( + label = "Ring only", + state = DeviceIconUM.Ring(mainColor = RingGreen), + ) + } + + item("ring_card") { + DeviceIconRow( + label = "Ring + card", + state = DeviceIconUM.Ring(mainColor = RingGreen, cardColor = CardBlue), + ) + } + + item("ring_two_cards") { + DeviceIconRow( + label = "Ring + 2 cards", + state = DeviceIconUM.Ring( + mainColor = RingGreen, + cardColor = CardBlue, + secondCardColor = CardGold, + ), + ) + } + + item("section_stubs") { + SectionTitle(text = "Stubs") + } + + repeat(3) { count -> + item("stub_$count") { + DeviceIconRow( + label = "Stub ($count card${if (count > 0) "s" else ""})", + state = DeviceIconUM.Stub(cardsCount = count), + ) + } + } + + item("section_mobile") { + SectionTitle(text = "Mobile") + } + + item("mobile") { + DeviceIconRow( + label = "Mobile wallet", + state = DeviceIconUM.Mobile, + ) + } + + item("section_sizes") { + SectionTitle(text = "Sizes") + } + + item("sizes") { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Bottom, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + listOf(24, 32, 40, 48).forEach { size -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TangemDeviceIcon( + state = DeviceIconUM.Card( + mainColor = CardBlue, + secondColor = CardGold, + thirdColor = CardPurple, + ), + modifier = Modifier.size(size.dp), + ) + Text( + text = "${size}dp", + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun DeviceIconRow(label: String, state: DeviceIconUM) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + TangemDeviceIcon( + state = state, + modifier = Modifier.size(40.dp), + ) + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt index 95a5c349d7..f034c7b02f 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/headerrow/TangemHeaderRowStory.kt @@ -88,6 +88,29 @@ private fun buildSampleRows(): List = listOf( title = stringReference("Account"), subtitle = stringReference("\$ 42,900.17"), ), + TangemHeaderRowUM( + id = "tail_text", + startIconUM = TangemIconUM.Currency(currencyIconState = CurrencyIconState.Locked), + tailUM = TangemRowTailUM.Text(text = stringReference("12 tokens")), + title = stringReference("Account"), + subtitle = stringReference("\$ 42,900.17"), + ), + TangemHeaderRowUM( + id = "tail_draggable", + startIconUM = TangemIconUM.Currency(currencyIconState = CurrencyIconState.Locked), + tailUM = TangemRowTailUM.Draggable(iconRes = R.drawable.ic_drag_24), + title = stringReference("Account"), + subtitle = stringReference("\$ 42,900.17"), + ), + TangemHeaderRowUM( + id = "clickable", + startIconUM = TangemIconUM.Currency(currencyIconState = CurrencyIconState.Locked), + tailUM = TangemRowTailUM.Icon(R.drawable.ic_arrow_collapse_24), + title = stringReference("Clickable row"), + subtitle = stringReference("\$ 42,900.17"), + isEnabled = true, + onItemClick = {}, + ), TangemHeaderRowUM( id = "title_only", title = stringReference("Account"), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt new file mode 100644 index 0000000000..22832e4de4 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.pagerindicator + +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory +import com.tangem.feature.tester.presentation.storybook.entity.TangemPagerIndicatorStory + +internal val tangemPagerIndicatorStoryFactory: StoryPageFactory = + StoryPageFactory { TangemPagerIndicatorStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt new file mode 100644 index 0000000000..fc770c4015 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/pagerindicator/TangemPagerIndicatorStory.kt @@ -0,0 +1,142 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.pagerindicator + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.TangemPagerIndicator +import com.tangem.core.ui.ds.TangemPagerIndicatorColors +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun TangemPagerIndicatorStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_page_counts") { + SectionTitle(text = "Page counts") + } + + listOf(1, 2, 3, 4, 5).forEach { pageCount -> + item("count_$pageCount") { + IndicatorRow(label = "$pageCount page(s)", pageCount = pageCount, currentPage = 0) + } + } + + item("section_many_pages") { + SectionTitle(text = "Many pages (>5)") + } + + item("many_start") { + IndicatorRow(label = "7 pages, at start", pageCount = 7, currentPage = 0) + } + + item("many_middle") { + IndicatorRow(label = "7 pages, at middle", pageCount = 7, currentPage = 3) + } + + item("many_end") { + IndicatorRow(label = "7 pages, at end", pageCount = 7, currentPage = 6) + } + + item("ten_start") { + IndicatorRow(label = "10 pages, at start", pageCount = 10, currentPage = 0) + } + + item("ten_middle") { + IndicatorRow(label = "10 pages, at middle", pageCount = 10, currentPage = 5) + } + + item("ten_end") { + IndicatorRow(label = "10 pages, at end", pageCount = 10, currentPage = 9) + } + + item("section_active_positions") { + SectionTitle(text = "Active dot positions (5 pages)") + } + + repeat(4) { page -> + item("active_$page") { + IndicatorRow(label = "Active: page ${page + 1}", pageCount = 5, currentPage = page) + } + } + + item("section_overlay") { + SectionTitle(text = "With overlay background") + } + + item("overlay") { + IndicatorRowWithOverlay(pageCount = 5, currentPage = 2) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun IndicatorRow(label: String, pageCount: Int, currentPage: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + TangemPagerIndicator( + pagerState = rememberPagerState(currentPage) { pageCount }, + ) + } +} + +@Composable +private fun IndicatorRowWithOverlay(pageCount: Int, currentPage: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = "With overlay", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + TangemPagerIndicator( + pagerState = rememberPagerState(currentPage) { pageCount }, + colors = TangemPagerIndicatorColors.copy( + overlay = TangemTheme.colors2.tabs.backgroundSecondary, + ), + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt new file mode 100644 index 0000000000..947666e5a7 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.placeholder + +import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory + +internal val placeholderStoryFactory: StoryPageFactory = + StoryPageFactory { PlaceholderStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt new file mode 100644 index 0000000000..5eb7aa7d41 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/placeholder/PlaceholderStory.kt @@ -0,0 +1,254 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.placeholder + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.ChipShimmer +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SmallButtonShimmer +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun PlaceholderStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_rectangle") { + SectionTitle(text = "RectangleShimmer") + } + + item("rect_default") { + ShimmerRow(label = "Default") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + ) + } + } + + item("rect_narrow") { + ShimmerRow(label = "Narrow (40%)") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth(fraction = 0.4f) + .height(16.dp), + ) + } + } + + item("rect_tall") { + ShimmerRow(label = "Tall (48dp)") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + ) + } + } + + item("rect_custom_radius") { + ShimmerRow(label = "Custom radius (16dp)") { + RectangleShimmer( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + radius = 16.dp, + ) + } + } + + item("section_circle") { + SectionTitle(text = "CircleShimmer") + } + + item("circle_sizes") { + ShimmerRow(label = "Sizes: 24, 32, 40, 48") { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircleShimmer(modifier = Modifier.size(24.dp)) + CircleShimmer(modifier = Modifier.size(32.dp)) + CircleShimmer(modifier = Modifier.size(40.dp)) + CircleShimmer(modifier = Modifier.size(48.dp)) + } + } + } + + item("section_text") { + SectionTitle(text = "TextShimmer") + } + + item("text_title") { + ShimmerRow(label = "titleRegular44") { + TextShimmer( + style = TangemTheme.typography2.titleRegular44, + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + ) + } + } + + item("text_heading") { + ShimmerRow(label = "headingSemibold22") { + TextShimmer( + style = TangemTheme.typography2.headingSemibold22, + modifier = Modifier.fillMaxWidth(fraction = 0.6f), + ) + } + } + + item("text_body") { + ShimmerRow(label = "bodyRegular16") { + TextShimmer( + style = TangemTheme.typography2.bodyRegular16, + modifier = Modifier.fillMaxWidth(fraction = 0.7f), + ) + } + } + + item("text_caption") { + ShimmerRow(label = "captionRegular12") { + TextShimmer( + style = TangemTheme.typography2.captionRegular12, + modifier = Modifier.fillMaxWidth(fraction = 0.4f), + ) + } + } + + item("text_size_height") { + ShimmerRow(label = "bodyRegular16 (textSizeHeight)") { + TextShimmer( + style = TangemTheme.typography2.bodyRegular16, + textSizeHeight = true, + modifier = Modifier.fillMaxWidth(fraction = 0.5f), + ) + } + } + + item("section_button_chip") { + SectionTitle(text = "SmallButtonShimmer & ChipShimmer") + } + + item("small_button") { + ShimmerRow(label = "SmallButtonShimmer") { + SmallButtonShimmer() + } + } + + item("small_button_icon") { + ShimmerRow(label = "SmallButtonShimmer (with icon)") { + SmallButtonShimmer(withIcon = true) + } + } + + item("chip") { + ShimmerRow(label = "ChipShimmer") { + ChipShimmer() + } + } + + item("section_composition") { + SectionTitle(text = "Skeleton composition") + } + + item("card_skeleton") { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = "Typical card loading state", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CircleShimmer(modifier = Modifier.size(40.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TextShimmer( + style = TangemTheme.typography2.headingSemibold17, + modifier = Modifier.width(120.dp), + ) + TextShimmer( + style = TangemTheme.typography2.captionRegular13, + modifier = Modifier.width(80.dp), + ) + } + } + } + } + + item("list_skeleton") { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + text = "List loading state", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + repeat(3) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + RectangleShimmer(modifier = Modifier.size(40.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), + ) { + TextShimmer( + style = TangemTheme.typography2.bodyMedium16, + modifier = Modifier.fillMaxWidth(fraction = 0.6f), + ) + TextShimmer( + style = TangemTheme.typography2.captionRegular13, + modifier = Modifier.fillMaxWidth(fraction = 0.4f), + ) + } + } + } + } + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun ShimmerRow(label: String, content: @Composable () -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + content() + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt new file mode 100644 index 0000000000..9855967b04 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/Build.kt @@ -0,0 +1,9 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.progress + +import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory +import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory + +internal val progressIndicatorStoryFactory: StoryPageFactory = + StoryPageFactory { ProgressIndicatorStory } \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt new file mode 100644 index 0000000000..85ef01a6ea --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/progress/ProgressIndicatorStory.kt @@ -0,0 +1,132 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.progress + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.progress.TangemLinearProgressIndicatorWithDot +import com.tangem.core.ui.res.TangemTheme + +@Composable +internal fun ProgressIndicatorStory(modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("section_progress") { + SectionTitle(text = "Progress values") + } + + listOf(0f, 0.25f, 0.5f, 0.75f, 1f).forEach { progress -> + item("progress_$progress") { + ProgressRow(label = "${(progress * 100).toInt()}%", progress = progress) + } + } + + item("section_heights") { + SectionTitle(text = "Track heights") + } + + listOf(4, 6, 8).forEach { height -> + item("height_$height") { + ProgressRow(label = "${height}dp track", progress = 0.5f, height = height) + } + } + + item("section_colors") { + SectionTitle(text = "Color variants") + } + + item("accent") { + ProgressRowWithColors( + label = "Accent", + progress = 0.6f, + dotColor = TangemTheme.colors2.fill.status.accent, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + + item("warning") { + ProgressRowWithColors( + label = "Warning", + progress = 0.4f, + dotColor = TangemTheme.colors2.fill.status.warning, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + + item("attention") { + ProgressRowWithColors( + label = "Attention", + progress = 0.8f, + dotColor = TangemTheme.colors2.fill.status.attention, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} + +@Composable +private fun ProgressRow(label: String, progress: Float, height: Int = 6) { + ProgressRowWithColors( + label = label, + progress = progress, + height = height, + dotColor = TangemTheme.colors2.fill.status.accent, + bgColor = TangemTheme.colors2.graphic.neutral.primaryInvertedConstant, + ) +} + +@Composable +private fun ProgressRowWithColors( + label: String, + progress: Float, + dotColor: androidx.compose.ui.graphics.Color, + bgColor: androidx.compose.ui.graphics.Color, + height: Int = 6, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + TangemLinearProgressIndicatorWithDot( + progress = { progress }, + dotColor = dotColor, + backgroundColor = bgColor, + modifier = Modifier + .fillMaxWidth() + .height(height.dp), + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt new file mode 100644 index 0000000000..21b9a31e14 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/Build.kt @@ -0,0 +1,19 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.tab + +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemTabStory { + return TangemTabStory( + checkedIndex = 0, + onCheckedIndexChange = { index -> + updateStory { it.copy(checkedIndex = index) } + }, + ) +} + +internal val tangemTabStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/TangemTabStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/TangemTabStory.kt new file mode 100644 index 0000000000..f734c1027c --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/tab/TangemTabStory.kt @@ -0,0 +1,127 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.tab + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.tabs.TangemTab +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory + +@Composable +internal fun TangemTabStory(state: TangemTabStory, modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + item("interactive") { + TabSection(title = "Interactive") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("Markets", "Portfolio", "Activity").forEachIndexed { index, label -> + TangemTab( + text = stringReference(label), + isChecked = state.checkedIndex == index, + onCheckedChange = { if (it) state.onCheckedIndexChange(index) }, + ) + } + } + } + } + + item("checked") { + TabSection(title = "Checked") { + TangemTab( + text = stringReference("Markets"), + isChecked = true, + onCheckedChange = {}, + ) + } + } + + item("unchecked") { + TabSection(title = "Unchecked") { + TangemTab( + text = stringReference("Markets"), + isChecked = false, + onCheckedChange = {}, + ) + } + } + + item("disabled_checked") { + TabSection(title = "Disabled (Checked)") { + TangemTab( + text = stringReference("Markets"), + isChecked = true, + onCheckedChange = {}, + isEnabled = false, + ) + } + } + + item("disabled_unchecked") { + TabSection(title = "Disabled (Unchecked)") { + TangemTab( + text = stringReference("Markets"), + isChecked = false, + onCheckedChange = {}, + isEnabled = false, + ) + } + } + + item("multiple_tabs") { + TabSection(title = "Multiple tabs row") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TangemTab( + text = stringReference("All"), + isChecked = true, + onCheckedChange = {}, + ) + TangemTab( + text = stringReference("Gainers"), + isChecked = false, + onCheckedChange = {}, + ) + TangemTab( + text = stringReference("Losers"), + isChecked = false, + onCheckedChange = {}, + ) + } + } + } + } +} + +@Composable +private fun TabSection(title: String, content: @Composable () -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = title, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + content() + } + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt new file mode 100644 index 0000000000..494ab36f6b --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/Build.kt @@ -0,0 +1,20 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.topbar + +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemTopBarStory { + return TangemTopBarStory( + selectedType = TangemTopBarType.Default, + onTypeChange = { type -> + updateStory { it.copy(selectedType = type) } + }, + ) +} + +internal val tangemTopBarStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/TangemTopBarStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/TangemTopBarStory.kt new file mode 100644 index 0000000000..4a0d026c91 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/topbar/TangemTopBarStory.kt @@ -0,0 +1,259 @@ +@file:Suppress("MagicNumber", "LongMethod") + +package com.tangem.feature.tester.presentation.storybook.page.topbar + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun TangemTopBarStory(state: TangemTopBarStory, modifier: Modifier = Modifier) { + LazyColumn( + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .statusBarsPadding() + .fillMaxSize() + .background(TangemTheme.colors2.surface.level1), + ) { + stickyHeader("type_toggle") { + TypeToggle( + selected = state.selectedType, + onSelect = state.onTypeChange, + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + item("title_only") { + TopBarVariant(label = "Title only") { + TangemTopBar( + title = stringReference("Wallet"), + type = state.selectedType, + startAction = null, + ) + } + } + + item("title_subtitle") { + TopBarVariant(label = "Title + Subtitle") { + TangemTopBar( + title = stringReference("Wallet"), + subtitle = stringReference("3 cards"), + type = state.selectedType, + startAction = null, + ) + } + } + + item("back_action") { + TopBarVariant(label = "Back action + Title") { + TangemTopBar( + title = stringReference("Send"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + ) + } + } + + item("back_and_end") { + TopBarVariant(label = "Back + Title + End action") { + TangemTopBar( + title = stringReference("Token Details"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + onClick = {}, + ), + ), + ) + } + } + + item("back_and_two_end") { + TopBarVariant(label = "Back + Title + 2 End actions") { + TangemTopBar( + title = stringReference("Settings"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_information_24, + onClick = {}, + ), + TangemTopBarActionUM( + iconRes = R.drawable.ic_close_24, + onClick = {}, + ), + ), + ) + } + } + + item("ghost_actions") { + TopBarVariant(label = "Ghost mode actions (progress=1)") { + TangemTopBar( + title = stringReference("Portfolio"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_tangem_24, + onClick = {}, + ghostModeProgress = 1f, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + onClick = {}, + ghostModeProgress = 1f, + ), + ), + ) + } + } + + item("non_actionable") { + TopBarVariant(label = "Non-actionable icons") { + TangemTopBar( + title = stringReference("Details"), + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_tangem_24, + isActionable = false, + ), + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_more_default_24, + isActionable = false, + ), + ), + ) + } + } + + item("title_icon") { + TopBarVariant(label = "Title with icon") { + TangemTopBar( + title = stringReference("Wallet"), + titleIconRes = R.drawable.ic_tangem_24, + type = state.selectedType, + startAction = TangemTopBarActionUM( + iconRes = R.drawable.ic_back_24, + onClick = {}, + ), + ) + } + } + + item("no_title") { + TopBarVariant(label = "No title (end action only)") { + TangemTopBar( + type = state.selectedType, + startAction = null, + endActions = persistentListOf( + TangemTopBarActionUM( + iconRes = R.drawable.ic_close_24, + onClick = {}, + ), + ), + ) + } + } + } +} + +@Composable +private fun TypeToggle( + selected: TangemTopBarType, + onSelect: (TangemTopBarType) -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(50) + Row( + modifier = modifier + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border(width = 1.dp, color = TangemTheme.colors2.border.neutral.secondary, shape = shape) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + TangemTopBarType.entries.forEach { type -> + TypeChip( + label = type.name, + selected = type == selected, + onClick = { onSelect(type) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun TypeChip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background(if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun TopBarVariant(label: String, content: @Composable () -> Unit) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + ) + content() + } + HorizontalDivider( + color = TangemTheme.colors2.border.neutral.secondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt index 3a8b75942d..26d7f561c6 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookListScreen.kt @@ -20,12 +20,18 @@ import com.tangem.feature.tester.presentation.storybook.page.badge.tangemBadgeSt import com.tangem.feature.tester.presentation.storybook.page.buttons.buttonsStoryFactory import com.tangem.feature.tester.presentation.storybook.page.checkbox.tangemCheckboxStoryFactory import com.tangem.feature.tester.presentation.storybook.page.contextmenu.tangemContextMenuStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.deviceicon.deviceIconStoryFactory import com.tangem.feature.tester.presentation.storybook.page.headerrow.tangemHeaderRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.message.tangemMessageStoryFactory import com.tangem.feature.tester.presentation.storybook.page.opportunities.opportunitiesBGStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.pagerindicator.tangemPagerIndicatorStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.placeholder.placeholderStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.progress.progressIndicatorStoryFactory import com.tangem.feature.tester.presentation.storybook.page.searchfield.tangemSearchFieldStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.tab.tangemTabStoryFactory import com.tangem.feature.tester.presentation.storybook.page.tabs.tangemSegmentedPickerStoryFactory import com.tangem.feature.tester.presentation.storybook.page.tokenrow.tangemTokenRowStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.topbar.tangemTopBarStoryFactory import com.tangem.feature.tester.presentation.storybook.page.typography.typographyStoryFactory private data class StoryItem(val title: String, val factory: StoryPageFactory) @@ -43,6 +49,12 @@ private fun buildStories() = listOf( StoryItem(title = "📋 Context Menu", factory = tangemContextMenuStoryFactory), StoryItem(title = "🔍 Search Field", factory = tangemSearchFieldStoryFactory), StoryItem(title = "🔤 Typography", factory = typographyStoryFactory), + StoryItem(title = "🧭 Top Bar", factory = tangemTopBarStoryFactory), + StoryItem(title = "🔀 Tab", factory = tangemTabStoryFactory), + StoryItem(title = "⚫ Pager Indicator", factory = tangemPagerIndicatorStoryFactory), + StoryItem(title = "💀 Placeholder", factory = placeholderStoryFactory), + StoryItem(title = "⏳ Progress Indicator", factory = progressIndicatorStoryFactory), + StoryItem(title = "💳 Device Icon", factory = deviceIconStoryFactory), ) @Composable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index e8e17c0645..7bce52bb28 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -5,8 +5,11 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.feature.tester.presentation.storybook.entity.ButtonsStory +import com.tangem.feature.tester.presentation.storybook.entity.DeviceIconStory import com.tangem.feature.tester.presentation.storybook.entity.NorthernLightsStory import com.tangem.feature.tester.presentation.storybook.entity.OpportunitiesBGStory +import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeStory import com.tangem.feature.tester.presentation.storybook.entity.StoryBookUM import com.tangem.feature.tester.presentation.storybook.entity.StoryList @@ -14,23 +17,33 @@ import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxSto import com.tangem.feature.tester.presentation.storybook.entity.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.entity.TangemContextMenuStory import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemPagerIndicatorStory import com.tangem.feature.tester.presentation.storybook.entity.TangemSearchFieldStory import com.tangem.feature.tester.presentation.storybook.entity.TangemSegmentedPickerStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory import com.tangem.feature.tester.presentation.storybook.entity.TypographyStory import com.tangem.feature.tester.presentation.storybook.page.background.NorthernLightsStory import com.tangem.feature.tester.presentation.storybook.page.badge.TangemBadgeStory import com.tangem.feature.tester.presentation.storybook.page.buttons.ButtonsStory +import com.tangem.feature.tester.presentation.storybook.page.deviceicon.DeviceIconStory import com.tangem.feature.tester.presentation.storybook.page.opportunities.OpportunitiesBGStory import com.tangem.feature.tester.presentation.storybook.page.checkbox.TangemCheckboxStory import com.tangem.feature.tester.presentation.storybook.page.message.TangemMessageStory +import com.tangem.feature.tester.presentation.storybook.page.pagerindicator.TangemPagerIndicatorStory +import com.tangem.feature.tester.presentation.storybook.page.placeholder.PlaceholderStory +import com.tangem.feature.tester.presentation.storybook.page.progress.ProgressIndicatorStory +import com.tangem.feature.tester.presentation.storybook.page.tab.TangemTabStory import com.tangem.feature.tester.presentation.storybook.page.tabs.TangemSegmentedPickerStory import com.tangem.feature.tester.presentation.storybook.page.tokenrow.TangemTokenRowStory +import com.tangem.feature.tester.presentation.storybook.page.topbar.TangemTopBarStory import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory import com.tangem.feature.tester.presentation.storybook.page.contextmenu.TangemContextMenuStory import com.tangem.feature.tester.presentation.storybook.page.searchfield.TangemSearchFieldStory import com.tangem.feature.tester.presentation.storybook.page.typography.TypographyStory +@Suppress("CyclomaticComplexMethod") @Composable internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) { BackHandler(onBack = state.onBackClick) @@ -54,6 +67,12 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemContextMenuStory -> TangemContextMenuStory(state = storyState) is TangemSearchFieldStory -> TangemSearchFieldStory(state = storyState) is TypographyStory -> TypographyStory(state = storyState) + is TangemTopBarStory -> TangemTopBarStory(state = storyState) + is TangemTabStory -> TangemTabStory(state = storyState) + TangemPagerIndicatorStory -> TangemPagerIndicatorStory() + PlaceholderStory -> PlaceholderStory() + ProgressIndicatorStory -> ProgressIndicatorStory() + DeviceIconStory -> DeviceIconStory() } } } \ No newline at end of file From a47b0447f65f0a733a26347566f88583235ebd17 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Apr 2026 10:25:49 +0000 Subject: [PATCH 009/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 278d64c66f..165d042410 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1485" +tangemBlockchainSdk = "develop-1482" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-603" +tangemCardSdk = "develop-602" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From bd411ae949fb3194047370ce4b6cd27d8aa7d0ee Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Apr 2026 15:58:34 +0500 Subject: [PATCH 010/206] Updated on 2026-08-14 --- .../com/tangem/scenarios/BaseScenarios.kt | 9 +++-- .../domain/sdk/impl/MockTangemSdkManager.kt | 14 ++++++++ .../domain/sdk/mocks/MockCardPickerDialog.kt | 33 +++++++++++++++++++ .../tap/domain/sdk/mocks/MockProvider.kt | 28 ++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 5 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockCardPickerDialog.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 85e746d709..2d11046a5c 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -15,11 +15,10 @@ fun BaseTestCase.scanCard( mockContent: MockContent? = null, isTwinsCard: Boolean = false, ) { - if (productType != null) { - MockProvider.setMocks(productType) - } - if (mockContent != null) { - MockProvider.setMocks(mockContent) + when { + mockContent != null -> MockProvider.setMocks(mockContent) + productType != null -> MockProvider.setMocks(productType) + else -> MockProvider.setMocks(ProductType.Wallet) } step("Click on 'Accept' button") { onDisclaimerScreen { acceptButton.clickWithAssertion() } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt index c70dc421f4..bc15466208 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt @@ -10,6 +10,7 @@ import com.tangem.common.KeyPair import com.tangem.common.SuccessResponse import com.tangem.common.authentication.keystore.DummyKeystoreManager import com.tangem.common.core.CardSessionRunnable +import com.tangem.common.core.TangemSdkError import com.tangem.common.core.UserCodeRequestPolicy import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.services.InMemoryStorage @@ -32,6 +33,8 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.visa.VisaCardActivationResponse import com.tangem.sdk.api.visa.VisaCardActivationTaskMode import com.tangem.tap.domain.sdk.mocks.MockProvider +import com.tangem.tap.domain.sdk.mocks.showMockCardPicker +import com.tangem.tap.foregroundActivityObserver @Suppress("TooManyFunctions") class MockTangemSdkManager( @@ -61,6 +64,17 @@ class MockTangemSdkManager( allowsRequestAccessCodeFromRepository: Boolean, shouldCheckIsAlreadyActivated: Boolean, ): CompletionResult { + if (!MockProvider.isPreset) { + val activity = foregroundActivityObserver.foregroundActivity + if (activity != null) { + val selectedMock = showMockCardPicker(activity) + if (selectedMock != null) { + MockProvider.setMocksWithoutPresetFlag(selectedMock) + } else { + return CompletionResult.Failure(TangemSdkError.UserCancelled()) + } + } + } return MockProvider.getScanResponse() } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockCardPickerDialog.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockCardPickerDialog.kt new file mode 100644 index 0000000000..301a9214d2 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockCardPickerDialog.kt @@ -0,0 +1,33 @@ +package com.tangem.tap.domain.sdk.mocks + +import androidx.appcompat.app.AlertDialog +import com.tangem.wallet.R +import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +internal suspend fun showMockCardPicker(activity: AppCompatActivity): MockContent? = withContext(Dispatchers.Main) { + suspendCancellableCoroutine { continuation -> + val mocks = MockProvider.availableMocks + val names = mocks.map { it.first }.toTypedArray() + + val dialog = AlertDialog.Builder(activity) + .setTitle(R.string.mock_card_picker_title) + .setItems(names) { _, which -> + if (continuation.isActive) { + continuation.resume(mocks[which].second) + } + } + .setOnCancelListener { + if (continuation.isActive) { + continuation.resume(null) + } + } + .create() + + continuation.invokeOnCancellation { activity.runOnUiThread { dialog.dismiss() } } + dialog.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt index 704c10dd50..cc16fe4302 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt @@ -11,10 +11,32 @@ object MockProvider { private var content: MockContent = getMockContent(ProductType.Wallet) + var isPreset: Boolean = false + private set + private var isEmulatingError: Boolean = false private var emulatedError: TangemError = TangemSdkError.TagLost() + val availableMocks: List> = listOf( + "Wallet" to WalletMockContent, + "Note" to NoteMockContent, + "Twins" to TwinsMockContent, + "Ring" to RingMockContent, + "Wallet 2" to Wallet2MockContent, + "Wallet 2 (No Backup)" to Wallet2NoBackupMockContent, + "Wallet 2 (No Backup, No Wallets)" to Wallet2NoBackupNoWalletsMockContent, + "Wallet 2 (Seed Phrase)" to Wallet2WithSeedPhraseMockContent, + "Shiba" to ShibaMockContent, + "Shiba (No Backup)" to ShibaNoBackupMockContent, + "Shiba (No Backup, No Wallets)" to ShibaNoBackupNoWalletsMockContent, + "Ed25519 Curve" to EdCurveMockContent, + "Secp256k1 Curve" to Secpk1CurveMockContent, + "Backup Wallet" to BackupWalletMockContent, + "Dev Wallet" to DevWalletMockContent, + "Firmware 4.12" to Firmware412MockContent, + ) + fun setEmulateError(error: TangemError? = null) { isEmulatingError = true error?.let { @@ -28,10 +50,16 @@ object MockProvider { fun setMocks(productType: ProductType) { content = getMockContent(productType) + isPreset = true } fun setMocks(mockContent: MockContent) { content = mockContent + isPreset = true + } + + fun setMocksWithoutPresetFlag(mockContent: MockContent) { + content = mockContent } fun getSuccessResponse() = CompletionResult.Success(content.successResponse).orFailure() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 250461aa6b..e1c13baa62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,5 +2,6 @@ Tangem + Select Mock Card From 49127d64dd97f29919b609ea11aaa7f3f33d12b5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Apr 2026 04:24:13 -0700 Subject: [PATCH 011/206] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 1 + .../components/TangemPayCardPageComponent.kt | 14 ++ .../DefaultTangemPayCardPageComponent.kt | 96 +++++++++ .../TangemPayCardPageScreenComponent.kt | 80 ++++++++ .../di/TangemPayDetailsFeatureModule.kt | 8 + .../tangempay/di/TangemPayModelModule.kt | 6 + .../tangempay/entity/TangemPayCardPageUM.kt | 39 ++++ .../tangempay/model/TangemPayCardPageModel.kt | 177 +++++++++++++++++ .../tangempay/ui/TangemPayCardPageScreen.kt | 182 ++++++++++++++++++ 9 files changed, 603 insertions(+) create mode 100644 features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 5b19f4c6fd..75603cdf2e 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1612,6 +1612,7 @@ Withdraw unavailable now You can\'t initiate swap or new withdrawal till the current one is finished Withdrawal in progress + Card settings Change PIN-code Come back to the app if you forget it. I understand that I will completely lose access to my Tangem Pay Card and all funds on it without the possibility of recovery diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt new file mode 100644 index 0000000000..9ec8ddecd1 --- /dev/null +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.tangempay.components + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayDetailsConfig + +interface TangemPayCardPageComponent : ComposableContentComponent { + data class Params( + val userWalletId: UserWalletId, + val config: TangemPayDetailsConfig, + ) + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt new file mode 100644 index 0000000000..a69dbe675f --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt @@ -0,0 +1,96 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.navigation.inner.InnerRouter +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( + @Assisted private val appComponentContext: AppComponentContext, + @Assisted private val params: TangemPayCardPageComponent.Params, +) : AppComponentContext by appComponentContext, TangemPayCardPageComponent { + + private val stackNavigation = StackNavigation() + + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) + + private val childStack = childStack( + key = "tangemPayCardPageInnerStack", + source = stackNavigation, + serializer = TangemPayDetailsInnerRoute.serializer(), + initialConfiguration = TangemPayDetailsInnerRoute.Details, + childFactory = ::screenChild, + ) + + @Suppress("ReusedModifierInstance") + @Composable + override fun Content(modifier: Modifier) { + val childStack by childStack.subscribeAsState() + Children( + stack = childStack, + animation = stackAnimation(), + ) { child -> + child.instance.Content(modifier = modifier) + } + } + + private fun screenChild( + config: TangemPayDetailsInnerRoute, + componentContext: ComponentContext, + ): ComposableContentComponent = when (config) { + TangemPayDetailsInnerRoute.Details -> TangemPayCardPageScreenComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = params, + ) + TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) + TangemPayDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + } + + private fun onChildBack() { + if (childStack.value.backStack.isEmpty()) { + router.pop() + } else { + stackNavigation.pop() + } + } + + @AssistedFactory + interface Factory : TangemPayCardPageComponent.Factory { + override fun create( + context: AppComponentContext, + params: TangemPayCardPageComponent.Params, + ): DefaultTangemPayCardPageComponent + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt new file mode 100644 index 0000000000..975ce82cf3 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -0,0 +1,80 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.NavigationBar3ButtonsScrim +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation +import com.tangem.features.tangempay.model.TangemPayCardPageModel +import com.tangem.features.tangempay.ui.TangemPayCardPageScreen + +internal class TangemPayCardPageScreenComponent( + private val appComponentContext: AppComponentContext, + private val params: TangemPayCardPageComponent.Params, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayCardPageModel = getOrCreateModel(params = params) + + private val containerParams = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ) + + private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( + appComponentContext = child("cardDetailsBlockComponent"), + params = TangemPayCardDetailsBlockComponent.Params(params = containerParams), + ) + + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = TangemPayDetailsNavigation.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() + + NavigationBar3ButtonsScrim() + TangemPayCardPageScreen( + state = state, + cardDetailsBlockComponent = cardDetailsBlockComponent, + cardDetailsState = cardDetailsState, + modifier = modifier, + ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + navigation: TangemPayDetailsNavigation, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + val context = childByContext(componentContext) + return when (navigation) { + is TangemPayDetailsNavigation.ViewPinCode -> TangemPayViewPinComponent( + appComponentContext = context, + params = TangemPayViewPinComponent.Params( + walletId = navigation.userWalletId, + cardId = navigation.cardId, + listener = model, + ), + ) + else -> error("Unsupported bottom sheet navigation: $navigation") + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt index de293a174d..df71114f7c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt @@ -1,6 +1,8 @@ package com.tangem.features.tangempay.di +import com.tangem.features.tangempay.components.DefaultTangemPayCardPageComponent import com.tangem.features.tangempay.components.DefaultTangemPayDetailsContainerComponent +import com.tangem.features.tangempay.components.TangemPayCardPageComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.model.listener.DefaultCardDetailsEventListener @@ -20,6 +22,12 @@ internal interface TangemPayDetailsFeatureModule { factory: DefaultTangemPayDetailsContainerComponent.Factory, ): TangemPayDetailsContainerComponent.Factory + @Binds + @Singleton + fun bindTangemPayCardPageComponentFactory( + factory: DefaultTangemPayCardPageComponent.Factory, + ): TangemPayCardPageComponent.Factory + @Binds @Singleton fun bindCardDetailsEventListener(impl: DefaultCardDetailsEventListener): CardDetailsEventListener diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index 2150e54279..d533bce2b9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.features.tangempay.model.TangemPayAddFundsModel import com.tangem.features.tangempay.model.TangemPayAddToWalletModel import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel +import com.tangem.features.tangempay.model.TangemPayCardPageModel import com.tangem.features.tangempay.model.TangemPayChangePinModel import com.tangem.features.tangempay.model.TangemPayDetailsModel import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel @@ -59,4 +60,9 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPayViewPinModel::class) fun bindTangemPayViewPinModel(model: TangemPayViewPinModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayCardPageModel::class) + fun bindTangemPayCardPageModel(model: TangemPayCardPageModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt new file mode 100644 index 0000000000..8a16bf6d94 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt @@ -0,0 +1,39 @@ +package com.tangem.features.tangempay.entity + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class TangemPayCardPageUM( + val addToWalletBlockState: AddToWalletBlockState? = null, + val settings: ImmutableList = persistentListOf( + TangemPayCardPageSetting.ChangePIN, + TangemPayCardPageSetting.FreezeCard, + ), + val onBackClick: () -> Unit, + val onSettingClick: (TangemPayCardPageSetting) -> Unit, +) { + companion object { + fun stub( + addToWalletBlockState: AddToWalletBlockState? = AddToWalletBlockState(onClick = {}, onClickClose = {}), + settings: ImmutableList = persistentListOf( + TangemPayCardPageSetting.ChangePIN, + TangemPayCardPageSetting.FreezeCard, + TangemPayCardPageSetting.ReplaceCard, + ), + ) = TangemPayCardPageUM( + addToWalletBlockState = addToWalletBlockState, + settings = settings, + onBackClick = {}, + onSettingClick = {}, + ) + } +} + +@Immutable +internal sealed class TangemPayCardPageSetting { + data object ChangePIN : TangemPayCardPageSetting() + data object FreezeCard : TangemPayCardPageSetting() + data object ReplaceCard : TangemPayCardPageSetting() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt new file mode 100644 index 0000000000..b08ca54fbb --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -0,0 +1,177 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.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.extensions.resourceReference +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.components.TangemPayCardPageComponent +import com.tangem.features.tangempay.components.ViewPinListener +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.AddToWalletBlockState +import com.tangem.features.tangempay.entity.TangemPayCardPageSetting +import com.tangem.features.tangempay.entity.TangemPayCardPageUM +import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.utils.TangemPayMessagesFactory +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayCardPageModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val uiMessageSender: UiMessageSender, +) : Model(), ViewPinListener { + + private val params: TangemPayCardPageComponent.Params = paramsContainer.require() + + private var currentFrozenState: TangemPayCardFrozenState = params.config.cardFrozenState + + private val addToWalletBannerJobHolder = JobHolder() + + val uiState: StateFlow + field = MutableStateFlow( + TangemPayCardPageUM( + onBackClick = router::pop, + onSettingClick = ::onSettingClick, + ), + ) + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + + init { + fetchAddToWalletBanner() + subscribeToCardFrozenState() + } + + private fun onSettingClick(setting: TangemPayCardPageSetting) = when (setting) { + TangemPayCardPageSetting.ChangePIN -> onClickChangePIN() + TangemPayCardPageSetting.FreezeCard -> onClickFreezeOrUnfreezeCard() + TangemPayCardPageSetting.ReplaceCard -> Unit // TODO v_rodionov #[REDACTED_TASK_KEY] + } + + private fun onClickChangePIN() { + if (!params.config.isPinSet) { + router.push(TangemPayDetailsInnerRoute.ChangePIN) + } else { + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.ViewPinCode( + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ), + ) + } + } + + private fun onClickFreezeOrUnfreezeCard() { + when (currentFrozenState) { + TangemPayCardFrozenState.Frozen -> uiMessageSender.send( + TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard), + ) + else -> uiMessageSender.send( + TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard), + ) + } + } + + private fun freezeCard() { + modelScope.launch { + cardDetailsRepository.freezeCard( + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ).onLeft { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) + uiMessageSender.send(message) + }.onRight { state -> + val message = if (state == TangemPayCardFrozenState.Frozen) { + SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)) + } else { + SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) + } + uiMessageSender.send(message) + } + } + } + + private fun unfreezeCard() { + modelScope.launch { + cardDetailsRepository.unfreezeCard( + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ).onLeft { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) + uiMessageSender.send(message) + }.onRight { state -> + val message = if (state == TangemPayCardFrozenState.Unfrozen) { + SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)) + } else { + SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) + } + uiMessageSender.send(message) + } + } + } + + private fun subscribeToCardFrozenState() { + cardDetailsRepository + .cardFrozenState(params.config.cardId) + .onEach { state -> currentFrozenState = state } + .launchIn(modelScope) + } + + private fun fetchAddToWalletBanner() { + modelScope.launch { + val isDone = cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true + if (!isDone) { + uiState.update { state -> + state.copy( + addToWalletBlockState = AddToWalletBlockState( + onClick = ::onClickAddToWallet, + onClickClose = ::onClickCloseBanner, + ), + ) + } + } + }.saveIn(addToWalletBannerJobHolder) + } + + private fun onClickAddToWallet() { + router.push(TangemPayDetailsInnerRoute.AddToWallet) + } + + private fun onClickCloseBanner() { + modelScope.launch { + cardDetailsRepository.setAddToWalletAsDone(params.userWalletId) + uiState.update { it.copy(addToWalletBlockState = null) } + }.saveIn(addToWalletBannerJobHolder) + } + + override fun onClickChangePin() { + bottomSheetNavigation.dismiss() + router.push(TangemPayDetailsInnerRoute.ChangePIN) + } + + override fun onDismissViewPin() { + bottomSheetNavigation.dismiss() + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt new file mode 100644 index 0000000000..20a8d78463 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -0,0 +1,182 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.util.fastForEach +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayCardPageSetting +import com.tangem.features.tangempay.entity.TangemPayCardPageUM +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun TangemPayCardPageScreen( + state: TangemPayCardPageUM, + cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, + cardDetailsState: TangemPayCardDetailsUM, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + AppBarWithBackButton( + modifier = Modifier.statusBarsPadding(), + onBackClick = state.onBackClick, + ) + }, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude(WindowInsets.navigationBars), + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(scaffoldPaddings), + contentPadding = PaddingValues( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16), + ) { + item(key = "Card") { + cardDetailsBlockComponent.CardDetailsBlockContent( + modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + state = cardDetailsState, + ) + } + if (state.addToWalletBlockState != null) { + item(key = "GooglePay") { + TangemPayAddToWalletBlock( + state = state.addToWalletBlockState, + ) + } + } + item(key = "Settings") { + TangemPayCardPageSettingsBlock( + settings = state.settings, + onSettingClick = state.onSettingClick, + ) + } + } + } +} + +@Composable +private fun TangemPayCardPageSettingsBlock( + settings: ImmutableList, + onSettingClick: (TangemPayCardPageSetting) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersMedium, + ), + ) { + Text( + modifier = Modifier.padding( + start = TangemTheme.dimens.spacing12, + top = TangemTheme.dimens.spacing12, + bottom = TangemTheme.dimens.spacing4, + ), + text = stringResourceSafe(R.string.tangempay_card_page_settings_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + settings.fastForEach { item -> + TangemPayCardPageSettingRow( + item = item, + onClick = { onSettingClick(item) }, + ) + } + } +} + +@Composable +private fun TangemPayCardPageSettingRow( + item: TangemPayCardPageSetting, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(TangemTheme.dimens.spacing12), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = stringResourceSafe(item.titleRes), + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + } +} + +private val TangemPayCardPageSetting.titleRes + get() = when (this) { + TangemPayCardPageSetting.ChangePIN -> R.string.tangempay_card_details_change_pin + TangemPayCardPageSetting.FreezeCard -> R.string.tangempay_card_details_freeze_card + TangemPayCardPageSetting.ReplaceCard -> R.string.common_error // TODO v_rodionov #[REDACTED_TASK_KEY] + } + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun preview() = TangemThemePreview { + TangemPayCardPageScreen( + state = TangemPayCardPageUM.stub(), + cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( + TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + numberShort = "··1245", + expiry = "••/••", + cvv = "•••", + onCopy = { _, _ -> }, + onClick = {}, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + ), + ), + cardDetailsState = TangemPayCardDetailsUM( + number = "•••• •••• •••• 1245", + numberShort = "··1245", + expiry = "••/••", + cvv = "•••", + onCopy = { _, _ -> }, + onClick = {}, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + ), + ) +} \ No newline at end of file From b58be5612edf400089a07dc0585926f6bedc43d4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 6 Apr 2026 12:35:57 +0300 Subject: [PATCH 012/206] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 41 +- core/res/src/main/res/values-es/strings.xml | 105 ++- core/res/src/main/res/values-fr/strings.xml | 8 + core/res/src/main/res/values-ja/strings.xml | 8 +- .../src/main/res/values-pt-rBR/strings.xml | 27 + .../src/main/res/values-uk-rUA/strings.xml | 8 + .../src/main/res/values-zh-rCN/strings.xml | 653 +++++++++++++++++- core/res/src/main/res/values/strings.xml | 8 + features/tokendetails/impl/build.gradle.kts | 8 + .../DefaultTokenDetailsComponent.kt | 1 - .../model/TokenDetailsDialogFactory.kt | 73 ++ .../tokendetails/model/TokenDetailsModel.kt | 220 +++--- .../state/TokenDetailsStateController.kt | 54 ++ .../tokendetails/state/TokenDetailsUM.kt | 36 +- ...InitializeWithCryptoCurrencyTransformer.kt | 23 + .../transformer/SetTopBarTitleTransformer.kt | 78 +++ .../UpdateTopBarMenuTransformer.kt | 51 ++ .../tokendetails/ui/TokenDetailsScreen.kt | 148 +++- .../tokendetails/ui/TokenDetailsTopBar.kt | 478 +++++++++++++ ...ializeWithCryptoCurrencyTransformerTest.kt | 128 ++++ .../SetTopBarTitleTransformerTest.kt | 204 ++++++ .../UpdateTopBarMenuTransformerTest.kt | 214 ++++++ 22 files changed, 2423 insertions(+), 151 deletions(-) create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformer.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 07bf709771..6582af4d32 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -306,6 +306,7 @@ Stunde Importieren In Arbeit + Unzureichende Mittel Später Mehr erfahren %1$s übrig @@ -421,6 +422,7 @@ Token existiert bereits Dezimalzahl muss eine gültige Ganzzahl sein, bis zu %d Benutzerdefinierte Ableitung(derivation) + Die Funktion \"Dynamische Adressen\" ist aktiviert. Benutzerdefinierte \\"change\\" und \\"index\\" sind nicht verfügbar. E. g. m/00\'/0000\'/0\'/0/0 Benutzerdefinierte Ableitung (Derivation) eingeben Dezimalstellen @@ -473,6 +475,19 @@ Das Senden von Vermögenswerten in anderen Netzwerken führt zu dauerhaftem Verlust. %s Netzwerk Sende Geld nur mit + Dynamische Adressen + Dynamische Adressen ist aktiviert. Die benutzerdefinierten Funktionen \"Ändern\" und \"Index\" sind nicht verfügbar. + Einige Adressen fehlen + Verwenden Sie für jede Transaktion eine neue Adresse, um die Rückverfolgbarkeit zu verringern und den Datenschutz in der Kette zu verbessern. + Verbesserter Datenschutz + Einfacher Geldempfang in UTXO-basierten Netzwerken mit automatischer Adressgenerierung - keine manuelle Adressverwaltung erforderlich. + Nahtloser Empfang + Dynamische Adressen aktivieren + Bei dynamischen Adressen wird jedes Mal eine neue Adresse erstellt, um den Datenschutz zu gewährleisten - Ihr Gesamtguthaben bleibt gleich. + Dynamische Adressen können nicht aktiviert werden, da einige benutzerdefinierte Adressen/Tokens einen geänderten Ableitungspfad verwenden, der die erforderlichen Kriterien nicht erfüllt. + Nicht verfügbar + Wir können im Moment keine Verbindung zum Provider herstellen. Bitte versuchen Sie es später noch einmal. + Der Dienst ist nicht verfügbar. Bitte versuchen Sie es erneut. Beste Gelegenheiten Filter löschen Die Liste ist vorübergehend leer, da sie gerade aktualisiert wird. Schauen Sie in Kürze wieder rein. @@ -749,12 +764,18 @@ Die Daten dieses Abschnitts stammen aus den folgenden Netzwerken: %s Die Daten konnten nicht geladen werden... Keine Daten + **Hinzufügen zu Ihrem Portfolio**, um mit dem Kauf, Tausch oder Erhalt dieses Vermögenswerts zu beginnen + Zum Portfolio hinzufügen Marktimpuls Schnelle Aktionen + Alles löschen Markt durchsuchen + Neueste + In Ihrem Portfolio Ergebnis Token unter 100k USD Marktkapitalisierung anzeigen Token anzeigen + Krypto, Nachrichten und mehr Kein Ergebnis Netzwerk auswählen Wallet auswählen @@ -827,9 +848,11 @@ Position im Krypto-Rating zwischen allen Coins basierend auf der Marktkapitalisierung Marktposition Maximale Versorgung + Zirkulation und maximale Versorgung Die maximale Anzahl von Coins oder Tokens, die jemals für eine bestimmte Kryptowährung existieren können Maximale Versorgung Metriken + Nicht begrenzt Offizielle Links Preisleistung Aufbewahrungsort @@ -1104,10 +1127,14 @@ Einstellungen Du hast keinen Zugriff auf deine Kamera gewährt Kamerazugriff verweigert + Der angeforderte Token wurde Ihrer Wallet nicht hinzugefügt. Bitte fügen Sie ihn hinzu und versuchen Sie es erneut. + Token nicht hinzugefügt Dieser QR-Code konnte leider nicht erkannt werden. Unerkannter QR-Code Dieses Netzwerk wird von keinem der von Ihnen hinzugefügten Token unterstützt. Fügen Sie einen unterstützten Token hinzu, um Krypto zu senden. Keine unterstützten Token gefunden + Dieser QR-Code enthält Parameter, die nicht erkannt werden: %s. Einige Zahlungsdetails können verloren gehen, wenn Sie fortfahren. + Unbekannte Parameter Kein Memo erforderlich %1$s ( %2$s ) im %3$s Netzwerk %1$s im %2$s Netzwerk @@ -1222,6 +1249,10 @@ Memo: %s Ungültiges Memo Abdeckung der Netzgebühren + + %d Token ist mit dieser Adresse nicht kompatibel + %d -Tokens sind mit dieser Adresse nicht kompatibel + Nonce Eindeutige Nummer für jede Transaktion. Verwende diese Nummer, um eine ausstehende Transaktion erneut zu senden oder abzubrechen. Nonce eingeben… @@ -1489,14 +1520,18 @@ Fehler bei der Gebührenschätzung. Bitte sende dein Feedback an den Support. Du wechselst Der Tausch dieser Menge ausgewählter Token hat erhebliche Auswirkungen auf den Preis und verringert dein Ergebnis. + Aufgrund geringer Liquidität erhalten Sie möglicherweise deutlich weniger. Versuchen Sie es mit einem kleineren Betrag oder einem anderen Anbieter. Hoher Einfluss auf den Preis Unzureichende Mittel + Nicht genügend Geldmittel, um diese Transaktion abzuschließen. Verringern Sie den zu erhaltenden Betrag oder fügen Sie weitere Mittel hinzu. Erlaubnis erteilen Tauschen Tauschen... Du erhältst Token auswählen Nicht verfügbar + Nicht genug Liquidität für diesen Handel. Reduzieren Sie den Betrag oder wählen Sie einen anderen Anbieter. + Handel zu groß Wir freuen uns über Ihr Feedback Tangem Pay jetzt in der Beta Karte eingefroren @@ -1567,7 +1602,7 @@ Empfangen ist jetzt nicht verfügbar Aufdecken Details anzeigen - Tausche beliebige Vermögenswerte in Deinem Portfolio gegen eine Karte. + Tausche beliebige Vermögenswerte in Deinem Portfolio für deine Karte. Kartendetails Karte entsperren Komm zurück zur App, falls du es vergisst. @@ -1657,6 +1692,7 @@ Token in %%image%% %1$s Netzwerk Der %1$s (%2$s) Token ist die Hauptwährung im %3$s Netzwerk und kann nicht versteckt werden, solange du andere Token dieses Netzwerks in der Liste aktiv hast. %s kann nicht ausgeblendet werden + N / A QR-Code anzeigen Tausche diesen Token gegen einen anderen zu %1$s Servicegebühren von Februar %2$s-%3$s. Tausche mit Changelly, %s Gebühren @@ -1699,6 +1735,9 @@ Es ist ein Fehler aufgetreten. Fehlercode: %s. Bitte kontaktiere unseren Support. Verwende %s oder scanne eine Karte oder Ring, um Zugriff auf deine Wallet zu erhalten. Verbindung fehlgeschlagen: Diese dApp verwendet Wallet Connect Version 1.0, die nicht unterstützt wird. Bitte stelle sicher, dass die dApp Wallet Connect Version 2.0 unterstützt, um eine erfolgreiche Verbindung herzustellen. + Die vorherige Genehmigung wird widerrufen und eine neue erteilt. Das Netzwerk erhebt eine Token-Genehmigungsgebühr für jede dieser Aktionen. Als Beweis für den Widerruf wird in der Historie eine Transaktion mit einem Betrag von Null angezeigt. + Die Transaktion übersteigt den zuvor genehmigten Betrag.\nErlaubnis zum Fortfahren aktualisieren + Erlaubnis zur Aktualisierung Upgrade auf Hardware-Wallet Bleib auf dem Laufenden mit den neuesten Funktionen und Neuigkeiten Echtzeit-Warnungen für Transaktionen, Umtausch und wichtige Aktualisierungen. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index b257cbe438..970e3f83af 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -161,7 +161,7 @@ Se requiere atención Se produjo un error al procesar tu código promocional. Inténtalo de nuevo más tarde. Error de activación - Tu código promocional se activó correctamente. Una recompensa se acreditará en tu cuenta dentro de 14 días. + Su código promocional se activó correctamente. Se acreditará la recompensa en su cuenta dentro de 14 días. Código promocional activado Este código promocional ya ha sido utilizado y no puede activarse de nuevo. Código no disponible @@ -306,6 +306,7 @@ hora Importe En progreso + Fondos insuficientes Más tarde Más información %1$s quedan @@ -326,6 +327,7 @@ NFT No Ninguna dirección + Sin resultados No agregadas No disponible Ahora no @@ -384,6 +386,7 @@ A A %s Hoy + Token para enviar %d token %d tokens @@ -396,6 +399,7 @@ Entiendo Entiendo, continuar Hubo un error. Por favor inténtelo de nuevo. + Desbloquear Inaccesible Termine el staking Debido a limitaciones sobre %1$s, solo %2$d UTXO pueden caber en una sola transacción. Esto significa que solo puedes enviar %3$s o menos. Debe reducir la cantidad. @@ -405,7 +409,7 @@ semana con - Modo de rendimiento + Modo de Rendimiento Dirección de contrato copiada Redes disponibles La derivación de su token coincide con la derivación de %1$s. Su token se añadirá a esta cuenta. @@ -418,6 +422,7 @@ Este token ya existe Los decimales deben ser un número entero válido, hasta %d Derivación personalizada + La opción Direcciones dinámicas está habilitada. Las opciones personalizadas \\"cambiar\\" e \\"índice\\" no están disponibles. Por ejemplo m/00\'/0000\'/0\'/0/0 Introduzca una derivación personalizada Decimales @@ -460,7 +465,7 @@ Firmado Enviar comentarios Detalles - Puedes tener solo una billetera móvil a la vez. Puede actualizarse a una billetera fría Tangem o usarse junto con una nueva billetera fría. + Puede tener solo una billetera móvil. Puede actualizarse a una billetera fría Tangem o añadir una billetera de hardware nueva. Compruebe su conexión a internet o cambia a una red diferente Condiciones de uso Dirección por defecto @@ -470,6 +475,19 @@ Enviar activos a otras redes resultará en una pérdida permanente. Red de %s Envíe fondos utilizando solo + Direcciones dinámicas + La opción Direcciones dinámicas está habilitada. Las opciones personalizadas \"cambiar\" e \"índice\" no están disponibles. + Direcciones dinámicas habilitadas + Utilice una nueva dirección para cada transacción para reducir la trazabilidad y mejorar la privacidad on-chain. + Privacidad mejorada + Reciba fondos fácilmente en redes basadas en UTXO con generación automática de direcciones, sin necesidad de gestión manual de direcciones. + Recepción sin problemas + Habilitar direcciones dinámicas + Las direcciones dinámicas crean una nueva cada vez para mayor privacidad - su saldo total no varía. + Las direcciones dinámicas no pueden activarse porque algunas direcciones/tokens personalizadas utilizan una ruta de derivación modificada, que no cumple los criterios requeridos. + Direcciones dinámicas no disponibles + No podemos conectar con el proveedor en este momento. Vuelva a intentarlo más tarde. + Servicio no disponible. Por favor, inténtelo de nuevo. Las mejores oportunidades Limpiar filtro La lista está temporalmente vacía porque se está actualizando. Vuelva a consultarla en un momento. @@ -478,9 +496,9 @@ Filtrar por Mis redes Redes - Mayormente usado + Popular Sin resultados - Ganar + Modo Staking & Rendimiento Hola equipo de soporte, he encontrado un error con el código: %s Error de WalletConnect Ha usado una tarjeta de otra billetera. Toque la tarjeta asociada con esta billetera @@ -653,7 +671,7 @@ ¿Está seguro que desea hacer esto? Olvidar billetera Ir a copia de seguridad - Ver copia de seguridad + Ver frase de recuperación Olvidar la billetera Olvidar de todos modos Esta billetera tiene una copia de seguridad. Asegúrese de poder recuperarla antes de olvidarla. @@ -689,6 +707,9 @@ Acerque para escanear Toque para firmar Coloque la tarjeta o el anillo + Su billetera está sincronizada y lista. \n Faltan algunos tokens? + Billetera importada correctamente + Restaurando %d%% Ha actualizado la biometría, escanee su tarjeta o anillo para entrar Su saldo debe ser mayor que el valor de la tarifa para hacer una transferencia Saldo insuficiente @@ -700,6 +721,7 @@ Nivel de Mana Para hacer un seguimiento de sus criptomonedas y transacciones, agregue tokens Gestionar tokens + Escanee el código QR para enviar fondos o conectarse a una aplicación Para acceder a todas las redes necesita escanear la tarjeta Escanee su tarjeta o anillo Disfrute de %1$s comisiones de servicio en los swaps a través de Changelly a partir de febrero %2$s-%3$s @@ -734,15 +756,22 @@ APY %s Mi portafolio Mercado - Gane con Tangem + Modo Staking & Rendimiento Para generar direcciones para las redes seleccionadas, debe escanear su tarjeta Tangem Para agregar tokens, abra esta página o pulse sobre la barra de búsqueda + Deslice el dedo hacia arriba para explorar el mercado + Descubre nuevas gemas ocultas Los datos de este apartado proceden de las siguientes redes: %s No se pueden cargar los datos… Sin datos + **Añadir a su portafolio** para empezar a comprar, intercambiar o recibir este activo + En su portafolio Análisis del Mercado Acciones rápidas + Borrar todo Buscar tokens + Recientes + En su portafolio Resultado Ver tokens con marketcap inferior a 100.000$ Mostrar tokens @@ -799,6 +828,10 @@ Titulares El cambio en el número de poseedores de tokens dentro de un período de tiempo específico Titulares + D + M + S + A Ideas Enlaces Liquidez @@ -814,9 +847,11 @@ Posición en la clasificación de criptomonedas entre todas las monedas según la capitalización de mercado Evaluación de mercado Suministro máximo + Suministro circulante y máximo La cantidad máxima de monedas o tokens que pueden existir para una criptomoneda en particular Suministro máximo Métrica + Sin limitación Enlaces oficiales Rendimiento de precios Repositorio @@ -826,9 +861,11 @@ Suministro total La cantidad máxima de monedas o tokens que pueden existir para una criptomoneda en particular Suministro total + 24h Volumen de operaciones (24 horas) La cantidad total de una criptomoneda que se ha negociado en las últimas 24 horas, lo que indica el nivel de actividad y liquidez en el mercado. Volumen de operaciones (24 horas) + %s en total Volumen Tire hacia arriba o toque la barra de búsqueda para agregar tokens directamente desde el mercado Agregar tokens @@ -852,6 +889,7 @@ Tokens relacionados Noticias relacionadas Manténgase informado + Puntuación de tendencia NFC no está disponible en su dispositivo Acerca de NFT Activo NFT @@ -909,8 +947,8 @@ Obtén $10 en BTC con cada billetera \n ¡Date prisa! Black Friday: hasta 30% DESCUENTO Vamos - Crea el par perfecto de packs de Tangem. Por tiempo limitado. - 1+1: Compra una billetera y obtén un 50% dto. en la segunda + ¡Tiempo limitado! + 1+1: Compra una billetera y obtenga un 50% dto. en la segunda Únase ahora Comparta su código y gane 5 USDT por venta. Su amigo obtiene un 10% de descuento. ¡Obtenga RECOMPENSAS por cada amigo! @@ -1088,6 +1126,14 @@ Ajustes No ha dado acceso a su cámara Acceso a la cámara denegado + El token solicitado no se ha añadido a su billetera. Por favor, añádalo e inténtelo de nuevo. + Token no añadido + Lo sentimos, no se ha podido reconocer este código QR. + Código QR no reconocido + Esta red no es compatible con ninguno de sus tokens añadidos. Añada un token compatible para enviar cripto. + No se encontraron tokens compatibles + Este código QR contiene parámetros que no son reconocidos: %s. Si continúa, es posible que se pierdan algunos detalles de pago. + Parámetros desconocidos No se requiere nota %1$s (%2$s) en la red %3$s %1$s en la red %2$s @@ -1164,7 +1210,7 @@ Ha especificado una comisión inferior a la cantidad recomendada, lo que podría provocar un retraso en su transacción. ¿Continuar? Razón: %1$s\nCódigo: %2$s La transacción está incompleta - Convertir a otro token + Cambiar a otro token o red Montante Se enviará al destinatario Puede establecer su tarifa de transacción ajustando el valor en el campo Satoshi por vByte. @@ -1202,6 +1248,10 @@ Memo: %s Memo no válido Cobertura de tarifa de red + + El token %d no es compatible con esta dirección + Los tokens %d no son compatibles con esta dirección + Nonce Número único para cada transacción. Utilícelo para reenviar o cancelar una transacción pendiente. Introduzca nonce... @@ -1229,6 +1279,11 @@ Limitación de transacciones Opcional Por favor, alinee su código QR con el cuadrado para escanearlo. Asegúrese de escanear la dirección de la red %s. + Al utilizar un tipo de cambio fijo, el importe que recibe queda garantizado en el momento de la operación. Esto le protege de las fluctuaciones de precio durante la transacción. + Tasa fija + Un tipo variable significa que la cantidad final que recibe puede variar ligeramente en función de las condiciones del mercado entre el momento en que inicia y finaliza el swap. + Tasa flotante + La tasa es fija Reciente Destinatario No es una dirección válida @@ -1272,6 +1327,7 @@ El destinatario recibe %s ¿Seguro que desea cancelar la conversión? Se borrarán sus datos anteriores. Eliminar conversión + Algo salió mal. Inténtelo de nuevo. Enviar con swap Transacción enviada Escanee la tarjeta/anillo que quiere configurar @@ -1463,14 +1519,18 @@ Error en la estimación de la tarifa. Envíe sus comentarios al servicio de asistencia. Usted intercambia Hacer un intercambio de esta cantidad del token seleccionado causará un impacto significativo en el precio y reducirá su resultado. + Es posible que reciba una cantidad significativamente menor debido a la baja liquidez. Pruebe con una cantidad menor o con otro proveedor. Alto impacto en los precios Fondos insuficientes + No hay fondos suficientes para completar esta transacción. Reduzca el importe a recibir o añada más fondos. Dar autorización Intercambiar Intercambiando... Usted recibe Elige token no disponible + No hay liquidez suficiente para esta operación. Reduzca el importe o elija otro proveedor. + Operación demasiado grande Nos encantaría recibir tus comentarios Tangem Pay ya está en beta Tarjeta congelada @@ -1509,6 +1569,7 @@ Ocultar verificación de la pantalla Agregar fondos Opciones de recarga + Añadir a Google Wallet Número de tarjeta Modificar PIN La tarjeta está completamente lista para pagos. @@ -1596,7 +1657,7 @@ Sesión expirada Restablecer acceso Usa USDC para pagos cotidianos - Tangem Pay temporalmente no disponible + Tangem Pay no está disponible temporalmente. Tangem Pay Haga clic en el botón de abajo para restaurar el acceso Tu saldo USDC Polygon on-chain difiere de tu saldo de tarjeta y se actualiza dentro de 2 días hábiles después de una compra. Los fondos de compras reembolsadas no se devolverán a tu saldo on-chain ni estarán disponibles para retiro, pero permanecerán en tu saldo de tarjeta para compras. @@ -1630,6 +1691,7 @@ Token en la red %%image%% %1$s El token %1$s (%2$s) es la moneda principal en la red %3$s y no se puede ocultar mientras tengas otros tokens de esta red en la lista No se puede ocultar %s + N/A Mostrar código QR Cambie este token por otro por una tarifa de servicio de %1$s del %2$s al %3$s de febrero. Intercambie con Changelly, %s comisiones @@ -1672,6 +1734,9 @@ Hemos encontrado un error. Código de error: %s. Póngase en contacto con nuestro servicio de soporte. Use %s o escanee una tarjeta/anillo para tener acceso a su billetera Error de conexión: Esta dApp utiliza la versión 1.0 de Wallet Connect, que no es compatible. Asegúrese de que la dApp sea compatible con la versión 2.0 de Wallet Connect para conectarse correctamente. + Se revocará el permiso anterior y se emitirá uno nuevo. La red cobrará una tarifa de aprobación de tokens por cada una de estas acciones. Verá una transacción de importe cero en el historial como comprobante de la revocación. + La transacción excede el límite de permisos otorgados previamente.\nActualice los permisos para continuar. + Actualizar permisos Actualización a billetera de hardware Manténgase actualizado con las últimas funciones y noticias Alertas en tiempo real de transacciones, intercambios y actualizaciones críticas. @@ -1842,7 +1907,7 @@ Este código de acceso protege su billetera y se utiliza para iniciar sesión y firmar transacciones. Establecer/Cambiar código de acceso Cambiar código de acceso - Manténgase informado sobre las transacciones entrantes de la billetera y las actualizaciones de Tangem. + Reciba notificaciones push para las transacciones entrantes en su billetera. Es posible que las notificaciones push no funcionen actualmente en dispositivos Huawei. Estamos trabajando activamente en una solución y la publicaremos en una próxima actualización. ¡Gracias por su comprensión! Notificaciones de transacciones Establecer código de acceso @@ -1870,7 +1935,7 @@ Según la documentación oficial de Clore, todas las monedas recibidas antes del 21 de diciembre serán migradas a Clore (token ERC-20); las monedas recibidas después de esa fecha no lo serán. Se está desarrollando una solución de transferencia — mantente atento. Mensaje Abrir el portal de reclamaciones - Para mantener el acceso a sus fondos, comience la migración de acuerdo con las pautas oficiales de Clore. + Para continuar utilizando sus tokens de Clore, debe completar la migración de tokens de acuerdo con la información del Portal de Reclamación. Migración de la red Clore Firmar Firma @@ -1925,7 +1990,7 @@ La red no está disponible actualmente. Por favor, inténtalo de nuevo más tarde. La red no está disponible Recargue su billetera - Su billetera no ha sido respaldado. Realice este procedimiento para proteger sus activos ahora. + Su billetera no tiene aún una copia de seguridad. Hágala ahora para proteger sus activos. Falta backup Esta tarjeta se ha utilizado previamente para transacciones. Si la recibió de una fuente no confiable, considere retirar todos los fondos. Si es su tarjeta, no se requiere ninguna acción. La tarjeta ya ha firmado transacciones @@ -2099,7 +2164,7 @@ La tasa de red es actualmente demasiado alta para ejecutar préstamos. Los fondos se suministrarán una vez que baje a %1$s o menos. Mis fondos Su %1$s está ahora depositado en Aave y devenga intereses. Tiene un token%2$s, que representa su saldo y crece con el tiempo. Cuando deposite más fondos, estos se suministrarán a Aave para ganar intereses, menos una comisión por transacción. - Modo de rendimiento + Modo Rendimiento Ganancias totales Transferencias a Aave Explore Aave @@ -2111,7 +2176,7 @@ Tarifa máxima El monto mínimo se calcula en función de la comisión de la red actual para que no supere el 4%% del importe de la recarga, lo que hace que el mínimo sea %1$s (%2$s). Recarga mínima - Política de tarifas + Política de tarifas por recarga Tangem también cobra una comisión de servicio del 15% sobre el rendimiento obtenido. Sus fondos se suministrarán automáticamente a Aave una vez que las comisiones de red sean más bajas o su saldo alcance el importe mínimo requerido. Las comisiones son más altas de lo habitual debido a la alta actividad del mercado. Puede continuar ahora o volver a consultar más tarde cuando las comisiones sean más bajas. @@ -2131,7 +2196,7 @@ Aave es un protocolo on-chain para crear mercados de liquidez no custodiales y ganar intereses a tasa variable. Descentralizado y autocustodiado Al utilizar este servicio, usted acepta que el proveedor\n%1$s y %2$s - Conectar Aave + Conectar con Aave Consiga un %1$s%% APY\nen su saldo Aave %1$s%% - Tipo de interés variable Tipo de interés variable @@ -2144,7 +2209,7 @@ Cuando recargue, sus fondos se enviarán automáticamente a Aave para comenzar a ganar intereses. %s se deducirá para cubrir la tarifa de transacción. Suministro de activos Su %s se suministrará a Aave, pero seguirá siendo gestionable. - Ver política de tarifas + Ver política de tarifas por recarga Sus próximas recargas se suministrarán automáticamente a Aave. Todos tus futuros depósitos %1$s se suministrarán automáticamente a Aave. Activo @@ -2158,8 +2223,8 @@ APY Los intereses se devengan automáticamente Los intereses se devengan automáticamente - Modo de rendimiento - Procesando su depósito + Modo Rendimiento + Activando Modo Rendimiento Modo Rendimiento Implementación del contrato del Modo Rendimiento Modo Rendimiento activado diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 9075ee073a..5cfb59bdbc 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1086,6 +1086,14 @@ Paramètres Vous n\'avez pas donné accès à votre caméra Accès à la caméra refusé + Le jeton demandé n\'est pas ajouté à votre portefeuille. Veuillez l\'ajouter, puis réessayez. + Le jeton n\'a pas été ajouté + Désolé, ce code QR n\'a pas pu être reconnu. + Code QR non reconnu + Ce réseau n\'est pris en charge par aucun des jetons que vous avez ajoutés. Ajoutez un jeton pris en charge pour envoyer des cryptomonnaies. + Aucun jeton pris en charge n\'a été trouvé + Ce code QR contient des paramètres non reconnus : %s. Si vous continuez, certaines informations de paiement risquent d\'être perdues. + Paramètres inconnus Aucun mémo requis %1$s (%2$s) sur le réseau %3$s %1$s sur le réseau %2$s diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 404b67969c..c2dfddaf6f 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -415,6 +415,7 @@ トークンはすでに存在します 小数は%dまでの有効な整数である必要があります カスタム派生パス + 動的アドレスが有効です。カスタムの「change」と「index」は利用できません。 例:m/00\'/0000\'/0\'/0/0 カスタム派生パスを入力 小数 @@ -756,11 +757,13 @@ このセクションのデータは、次のネットワークから取得されています: %s データを読み込めません… データなし + **ポートフォリオに追加して**、この資産の買付・交換・受け取りを始めましょう + ポートフォリオ内 マーケット動向 クイックアクション すべてクリア トークンを探す - 最近 + 最近の検索 ポートフォリオ内 結果 時価総額10万ドル以下のトークンを見る @@ -1712,6 +1715,9 @@ エラーが発生しました。エラー コード:%s。サポートにお問い合わせください。 %sを使用するか、カード / リングをスキャンしてウォレットにアクセスしてください 接続に失敗しました:このdAppは、サポートされていないWallet Connectバージョン1.0を使用しています。正常に接続するには、dAppがWallet Connectバージョン2.0をサポートしていることを確認してください。 + 以前の承認は取り消され、新しい承認が発行されます。これらの各操作には、ネットワークによるトークン承認手数料がかかります。履歴には、取り消しの証拠として金額0の取引が表示されます。 + 取引金額が、以前に許可された承認額を超えています。\n続行するには、承認内容を更新してください。 + 承認を更新 ハードウェアウォレットにアップグレード 最新の機能とニュースをお届けします 取引・スワップ・重要な更新に関するリアルタイム通知。 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 94c47b4783..e931a23bdc 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -422,6 +422,7 @@ O token já existe. O valor decimal deve ser um número inteiro válido, até um limite de %d Derivação personalizada + O recurso de Endereços Dinâmicos está ativado. As opções personalizadas \"alterar\" e \"indexar\" não estão disponíveis. Exemplo: m/00\'/0000\'/0\'/0/0 Insira uma derivação personalizada Decimais @@ -474,6 +475,19 @@ O envio de ativos por outras redes resultará em perda permanente. %s rede Envie fundos usando apenas + Endereços dinâmicos + O recurso de Endereços Dinâmicos está ativado. As opções personalizadas \"alterar\" e \"indexar\" não estão disponíveis. + Endereços dinâmicos ativados + Use um novo endereço para cada transação para reduzir a rastreabilidade e melhorar a privacidade na blockchain. + Privacidade aprimorada + Receba fundos facilmente em redes baseadas em UTXO com geração automática de endereços — sem necessidade de gerenciamento manual de endereços. + Recebimento perfeito + Habilitar endereços dinâmicos + Endereços dinâmicos criam um novo a cada vez para maior privacidade — seu saldo total permanece o mesmo. + Não é possível ativar os endereços dinâmicos porque alguns endereços/tokens personalizados usam um caminho de derivação modificado, que não atende aos critérios necessários. + Endereços dinâmicos indisponíveis + Não foi possível conectar-se ao provedor neste momento. Tente novamente mais tarde. + Serviço indisponível. Por favor, tente novamente. Melhores oportunidades Limpar filtro A lista está temporariamente vazia, pois está sendo atualizada. Volte daqui a pouco. @@ -750,9 +764,14 @@ Os dados desta seção são provenientes das seguintes redes: %s Não foi possível carregar os dados… Sem dados + **Adicione ao seu portfólio** para começar a comprar, trocar ou receber este ativo. + Em seu portfólio Pulso do mercado Ações rápidas + Limpar tudo Pesquisar tokens + Recentes + Em seu portfólio Resultado Veja tokens com capitalização de mercado inferior a 100 mil dólares. Mostrar tokens @@ -1114,6 +1133,8 @@ Código QR não reconhecido Esta rede não é compatível com nenhum dos tokens adicionados. Adicione um token compatível para enviar criptomoedas. Nenhum token compatível encontrado + Este código QR contém parâmetros que não são reconhecidos: %sAlgumas informações de pagamento podem ser perdidas se você continuar. + Parâmetros desconhecidos Não é necessário memorando %1$s (%2$s) sobre %3$s rede %1$s sobre %2$s rede @@ -1499,6 +1520,7 @@ Erro na estimativa de custos. Por favor, envie seu feedback para o suporte. Você troca Trocar essa quantidade de tokens selecionados causará um impacto significativo no preço e reduzirá seu resultado. + Você pode receber um valor significativamente menor devido à baixa liquidez. Tente um valor menor ou outro provedor. Alto impacto nos preços Fundos insuficientes Não há fundos suficientes para concluir esta transação. Reduza o valor a receber ou adicione mais fundos. @@ -1508,6 +1530,8 @@ Você recebe Escolha o token não disponível + Liquidez insuficiente para esta transação. \n o valor ou escolha outro provedor. + Negociação em grande escala Teremos todo o prazer em receber seu feedback. O Tangem Pay agora está em versão beta. Cartão bloqueado @@ -1711,6 +1735,9 @@ Ocorreu um erro. Código do erro: %sPor favor, entre em contato com nosso suporte. Usar %s ou escaneie um cartão/anel para acessar sua carteira. Falha na conexão: Este aplicativo descentralizado (dApp) usa a versão 1.0 do Wallet Connect, que não é compatível. Certifique-se de que o dApp seja compatível com a versão 2.0 do Wallet Connect para que a conexão seja estabelecida com sucesso. + A permissão anterior será revogada e uma nova será emitida. A rede cobrará uma taxa de aprovação de tokens para cada uma dessas ações. Você verá uma transação de valor zero no histórico como comprovante de revogação. + A transação excede o limite de permissão concedido anteriormente.\nAtualize a permissão para prosseguir. + Atualizar permissão Faça upgrade para uma carteira de hardware. Fique por dentro das últimas novidades e recursos. Alertas em tempo real para transações, câmbio e atualizações críticas. diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 8df7a978d6..ffcd7afc54 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1124,6 +1124,14 @@ Налаштування Ви не надали доступ до своєї камери Доступ до камери заборонено + Запитуваний токен не додано до вашого гаманця. Будь ласка, додайте його та спробуйте ще раз. + Токен не додано + На жаль, цей QR-код не вдалося розпізнати. + Нерозпізнаний QR-код + Ця мережа не підтримується жодним із ваших доданих токенів. Додайте підтримуваний токен, щоб надіслати криптовалюту. + Підтримуваних токенів не знайдено + Цей QR-код містить нерозпізнані параметри: %s. Деякі деталі платежу можуть бути втрачені, якщо ви продовжите. + Невідомі параметри Memo не вимагається %1$s (%2$s) у мережі %3$s %1$s у мережі %2$s diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 9a07214403..13a1972ba4 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1,19 +1,650 @@ - 添加代幣 - 沒有結果 - 要發送的代幣 - 解鎖 - 您的錢包已同步並準備就緒。\n缺少某些代幣? - 錢包導入成功 - 恢復%d%% + 更改代码 + 您的访问代码可解锁和保护您的钱包访问权限 + 无论如何都要用 + 这个访问码很容易猜到。 + 您不能创建超过 %1$s 账户。存档一个账户以添加新账户。 + 无法添加新帐户 + 账户已存档 + 已存档账户 + 您即将恢复 \"%1$s\"。 + 恢复账户 + 您已达到20个活跃账户的上限。请归档一个账户以恢复此账户。 + 无法恢复帐户 + 我们无法存档账户。请稍后再试。 + 此账户正在参与推荐计划。 + 此帐户无法存档 + 我们无法创建账户。请稍后再试。 + 账户已创建 + 存档... + 无法编辑帐户 + 账户已保存 + 奖励账户 + 已存在使用此名称的帐户。请选择其他名称。 + 帐户名已被使用 + 请稍后再试。如果问题仍然存在,请联系客服,我们将协助您解决问题。 + %1$s 在 %2$s + 主账户 + 您已超过 %1$s 活动账户的限制。存档一个以恢复 + 无法恢复帐户 + 账户已恢复 + 长按账户可重新排序 + 一些自定义代币将自动从 \"%1$s\" 移至 \"%2$s\",因为它们的派生词属于该账户。 + 部分自定义代币将自动移动 + 生物识别出现问题。请尝试重置设备上的生物识别技术或联系技术支持。 + 验证错误 + 设置访问码以启用生物识别功能 + 稍后会要求您提供访问代码以进行安全存储。 + 这将删除所有已保存的钱包访问码。您需要重新输入访问码才能使用钱包。 + 您的设备已关闭生物识别功能,因此无法使用此功能解锁钱包。请在设备设置中启用生物识别功能,即可再次使用此方法。 + 生物识别认证已禁用 + 您的生物识别尝试次数已达上限。请使用卡片/指环解锁钱包,或输入您的访问码。 + 生物识别认证已锁定 + 生物识别登录暂时锁定。请30秒后重试,或轻触设备或输入访问码解锁您的钱包。 + 您设备上的生物识别信息已更新。请选择您的钱包并输入其访问码以重新启用生物识别登录。 + 需要注意 + 处理您的优惠码时出错,请稍后再试。 + 激活错误 + 您的优惠码已成功激活。奖励将在14天内计入您的比特币账户。 + 促销代码已激活 + 此优惠码已被使用,无法再次激活。 + 代码不可用 + 此优惠码无效,无法激活。 + 无效代码 + 您需要提供比特币地址才能领取奖励。请将比特币地址添加到您的钱包,然后重试激活。 + 需要比特币地址 + 请重置下一个设备以继续。 + 钱包重置 + 所有 Tangem 设备均已重置。您现在可以继续升级钱包。 + 再次升级 + 重置完成 + 我们建议在此钱包中完成所有 Tangem 设备的重置过程。 + 部分 Tangem 设备仍需重置。 + 账户 + 账户 + 激活 + 添加 + 添加代币 + 已添加 + + 选择账户 + 联系客服 + 充值网络费 + 忘记 + + 从 %s + 开始 + 获取代币 + 保持到 %s + 余额不足 + 了解更多 + 传统比特币 + 锁定的钱包 + + %d网络 + + 新地址 + 新闻 + 无结果 + 不可用 + 或者 + 推荐 + 重置 + 出问题了 + Tangem + 点击并按住 + + 到 %s + 要发送的代币 + 无法加载数据…… + 我明白,请继续 + 解锁 + 钱包 + 收益模式 + 您的代币派生信息与 %1$s的派生信息一致。 您的代币将被添加到该账户。 + 派生属于另一个账户。 + 代币已添加到 %1$s 帐户 + 动态地址已启用。自定义\“更改”\和\“索引”\不可用。 + 升级到硬件钱包 + 您只能拥有一个手机钱包。将其升级为 Tangem 硬件钱包,或添加一个新的硬件钱包。 + 默认地址 + 传统 %s 地址 + %s 地址 + 动态地址 + 动态地址已启用。自定义“更改”和“索引”功能不可用。 + 已启用动态地址 + 每次交易都使用一个新地址,以减少可追溯性并提高链上隐私性。 + 增强隐私 + 通过自动生成地址,轻松在基于UTXO的网络中接收资金——无需手动管理地址。 + 无缝接收 + 启用动态地址 + 动态地址每次都会创建一个新地址,以增强隐私保护——您的总余额保持不变。 + 无法启用动态地址,因为某些自定义地址/代币使用的是修改后的派生路径,不符合所需的标准。 + 动态地址不可用 + 我们目前无法连接到服务提供商,请稍后再试。 + 服务不可用,请稍后再试。 + 最佳机会 + 清除筛选 + 列表正在刷新,暂时为空。请稍后再查看。 + 所有网络 + 所有类型 + 筛选方式 + 我的网络 + 网络 + 热门 + 无结果 + 质押与收益模式 + 将您投资组合中的任何资产交换为该代币 + 有竞争力的费率 + 速度越快,确认速度越快,但网络费用也越高。 %s + 选择速度 + 选择用于支付网络费用的代币。 %s + 选择代币 + 市场与新闻 + Tangem AI + 当下热门 + 备份问题 + 资金不足 + 转账费 + 地址直接在您的 Tangem 硬件钱包上生成,随时可用,并受到全面保护。 + 新地址 + 添加 Tangem 钱包 + 您的私钥将直接在 Tangem 卡内生成,并且永远不会离开该卡。 + 密钥生成 + 所有加密操作都在安全芯片内部进行,该芯片经过认证,可防止克隆和物理篡改。 + 硬件级安全 + 您是否允许“Tangem”使用生物识别认证来确认您的身份并打开应用程序? + 让您的加密货币安全离线存储。轻薄如信用卡,安全胜过银行金库。 + 创建一个安全的钱包并转移资金,以加强保护。 + 创建新钱包 + 使用卓越的 Tangem 硬件钱包,提升您的安全保障。 + 硬件钱包 + 将您当前的手机钱包转换为 Tangem 冷钱包。 + 升级当前钱包 + 先完成备份 + 其他方法 + 将您的恢复短语保存在安全的地方,保持其私密性以保护您的资金,并设置访问密码以提高安全性。 + 要使用访问码保护您的钱包,请完成备份过程。 + 要升级到硬件钱包,请完成备份过程。 + 将您的手机钱包升级到 Tangem 硬件钱包,享受最高级别的安全性。导入您的钱包或将资金转移到新钱包。 + 升级到冷钱包 + 随时安全地将手机钱包转移到 Tangem 卡片或指环上。 + 升级到硬件钱包 + 导入现有钱包 + 此恢复短语已导入 + 忘记钱包 + 此钱包将从您的设备中永久移除。 + 你确定要这么做吗? + 忘记钱包 + 转到备份 + 查看恢复短语 + 忘记钱包 + 确定忘记 + 这个钱包有备份。在忘记钱包之前,请确保你能找回备份。 + 如果您忘记了这个钱包而又没有备份,您将永远无法使用您的资金。 + 你确定要忘记这个钱包吗? + 我知道,如果我在移除钱包之前没有备份钱包,我将无法访问钱包。 + 我知道移除钱包并不会将其删除,只会将其从我的设备中移除。 + 升级 + 当您将手机钱包升级为 Tangem 卡或指环时,您的所有地址和余额都可以完全访问。 + 相同地址 + 无法升级。此设备上已有一个钱包。 + 请换一台设备。这台设备不能用于升级。 + 操作过程中发生错误。 + 在此过程中,您的资金将保持安全且完全可用。 + 获得资金 + 升级后,您的移动钱包将从应用程序中移除并存储在您的 Tangem 硬件钱包中。您的助记词将保留在您身边。 + 一般安全 + 私钥将从应用程序转移到您的 Tangem 硬件钱包。 + 私钥迁移 + 扫描设备 + 开始升级 + 您即将升级到我们的硬件钱包。它会将您的资产安全地存储在冷存储中。 + Tangem钱包 + 升级到我们的硬件钱包 + 使用 Tangem 的顶级硬件钱包,确保您的加密货币安全。 + 将您的钱包升级到硬件安全级别 + 您的钱包已同步并准备就绪。\n缺少某些代币? + 钱包已成功导入 + 恢复 %d%% + 扫描二维码即可发送资金或连接到应用程序 + 关于硬币 + 质押与收益模式 + 向上轻扫,探索市场 + 寻找新的隐藏宝石 + **添加到您的投资组合**,即可开始购买、交换或接收此资产 + 在您的投资组合中 + 市场脉搏 + 全部清除 + 最近的 + 在您的投资组合中 + 加密货币、新闻及更多 + 收益模式 + 代币已添加 - + - 使用固定利率時,您在置換交易時收到的金額將被鎖定,這可以保護您免受交易過程中價格波動的影響。 + 循环和最大供应 + 无限制 + 24小时 + %s 总共 + 添加更多代币 + 增强资产性能,并使其能够即时访问。 %s + 激活收益模式 + 创建手机钱包前必须更新至 %1$s + 手机钱包需要 %1$s 或更高版本 + 所有新闻 + 喜欢 + + 小时之前 + + + 分钟之前 + + 快速回顾 + 新闻 + 相关代币 + 相关新闻 + 随时了解最新动态 + 趋势得分 + 每个钱包均可获得价值 10 美元的 BTC \n快来领取! + 黑色星期五:最多可省 30 美元% + 我们出发吧 + 限时优惠! + 1+1:买一个钱包,优惠 50% + 购买加密货币 + 通过 SEPA 转账购买加密货币时,可享受 ** 0% 费用**。 + 使用 SEPA 购买加密货币 + 加入候补名单,即可获得一张与众不同的支付卡。 + Tangem Visa卡 + 条款和条件 + 存款 100 美元以上,持有 30 天,即可获得 10 美元 + 加入 \"收益模式 \"活动 + 生物识别 + 您只能拥有一个手机钱包。将其升级为 Tangem 硬件钱包,或添加一个新的硬件钱包。 + 所有优惠 + 此交易已处理完毕,无需进一步操作。 + 获得最佳利率... + 即时 + 服务由外部供应商提供。\nTangem对此不承担任何责任。 + 最快处理 + 付款方式 + + 提供者 + + 提供商 + 最近使用过 + 推荐 + + 至多%d天 + + %s 分钟 + 可从 + 最高可提供 + 你得到 + 服务由外部供应商提供。\nTangem 不承担任何责任。 + 至多 + 通过 %s + 您将支付 + %s 支持 + 从图库中选择 + 设置 + 您尚未授予摄像头访问权限 + 摄像头访问权限被拒绝 + 您请求的代币未添加到您的钱包。请添加后重试。 + 代币未添加 + 抱歉,无法识别此二维码。 + 无法识别的二维码 + 您添加的所有代币均不支持此网络。请添加受支持的代币以发送加密货币。 + 未找到支持的代币 + 此二维码包含无法识别的参数: %s如果您继续操作,部分支付信息可能会丢失。 + 未知参数 + 奖励地址 + 我明白,我将完全失去对 Tangem Pay 卡及其上所有资金的访问权,且无法挽回 + 所有 Tangem 设备均已重置。 + 激活过程中出了问题。请逐一重置卡片。 + 卡片验证失败 + 请重置下一个设备以继续 + 拥有 root 权限的设备安全性较低。您的数据可能面临额外的风险。 + 检测到 root 访问 + 您发送 + + 此地址与代币不兼容 + + 使用固定利率时,您在互换交易时收到的金额将被锁定。这可以保护您免受交易过程中价格波动的影响。 固定利率 - 浮動利率意味著您最終收到的金額可能會根據您開始和完成置換之間的市場情況略有變化。 - 浮動利率 + 浮动利率意味着您最终收到的金额可能会根据您开始和完成互换之间的市场情况略有变化。 + 浮动利率 利率固定 + 出错了。请再试一次。 + 简单易用 + 让您的加密货币安全离线存储。轻薄如信用卡,安全胜过银行金库。 + 无助记词 + 一流的硬件钱包 + Tangem 冷钱包 + 网络费用是处理和确认您在区块链上的交易时所需的小额费用。 + 要开始质押,您必须通过交易充值 1 TON 来激活您的 TON 账户。资金会保留在您的账户中,因为此步骤仅用于激活账户以进行质押。 + 激活账户 + 网络费用已更改。请在继续操作前查看新金额。 + 网络费用已更新 + 年利率 %1$s%% + 您的质押奖励将在 5 个周期(约 25 天)后开始发放,在此期间您的委托将由网络注册和统计。 + 年收益率 + 年利率 (APY):显示您一年内通过复利计算可获得的总利息。复利是指您获得的利息会添加到您的本金中,因此您还可以获得该利息的利息。 + APY + 奖励将累积到您的质押余额中。已赚取资金: %s + 最高金额: %s + 要开始质押,您需要先激活您的 TON 账户。 + 激活账户 + Solana 的奖励会自动添加到您的质押余额中,无法单独显示。 + 批准后,您即允许智能合约在未来的交易中使用您的代币。 + 还在寻找其他代币?\n尝试搜索或探索其他加密货币! + 搜索任何代币,即使它还不在你的列表中。 + 使用搜索查找所需内容 + 您的资产 + 由于流动性低,您收到的资金可能会大大减少。请尝试较小的金额或另一个提供商。 + 账户余额不足,无法完成此交易。请减少收款金额或增加余额。 + 互换... + 此交易流动性不足,请减少金额或选择其他供应商。 + 交易额过大 + 我们非常乐意收到您的反馈。 + Tangem Pay 现已进入测试阶段 + 卡片已冻结 + 卡片支付 + 存款 + 争议 + 探索交易 + 服务费 + 费用 + 确保您的资金安全。您可以随时解冻。 + 冻结您的卡片? + 冻结卡片失败,请稍后再试。 + 冻结 + 您的卡片已被冻结。 + 获取帮助 + 原因: %s + %s · %s + MCC %s + 其他 + 无法在已 root 的设备上使用 + 已完成 + 拒绝 + 待定 + 已反转 + 条款、费用和限制 + 条款和限制 + 银行拒绝了这项交易请求。 + 这笔费用用于支付您办理转账时的费用。 + 商家部分或全部撤销了交易 + 继续使用您的资金。您可以随时冻结资金。 + 要解冻您的卡片? + 卡片解冻失败,请稍后再试。 + 您的卡片已解冻。 + 提款 + 无法在已root的设备上使用 + 从主屏幕隐藏 KYC 页面 + 增加资金 + 充值选项 + 添加到 Google 钱包 + 卡号 + 更改PIN码 + 该卡已完全准备好用于支付。 + 已创建 PIN 码 + CVC + 数据加载失败,请稍后再试。 + 到期 + 冻结卡片 + 隐藏详情 + 隐藏 + 打开 Google 钱包 + 只需轻点几下即可设置 Tangem Pay,然后即可开始使用 Google Pay 付款。 + 只需轻点几下即可设置 Tangem Pay,然后即可开始使用 Apple Pay 付款。 + 将您的卡片添加到 Google Pay + 将您的卡片添加到 Apple Pay + 打开 Google 钱包 + 点击右上角的“+”按钮 + 打开 Apple 钱包 + 点击“添加卡片” + 点击“借记卡或信用卡” + 手动输入卡片信息 + 使用发送到您设备上的 OTP 来验证卡片。 + 一切就绪!您的卡已可以使用。 + 将卡片添加到 Google Pay + 将卡片添加到 Apple Pay + PIN码 + 分享您的地址或出示二维码 + 检测到技术问题。请稍后再试或联系技术支持。 + 目前无法接收 + 显示 + 显示详情 + 将您投资组合中的任何资产互换到卡片 + 卡片详情 + 解冻卡片 + 如果忘记了,请返回应用查看。 + 您的PIN码 + 提款 + 目前无法提款 + 当前提款交易完成之前,您无法发起新的提款交易或互换交易。 + 提款进行中 + 卡片设置 + 更改PIN码 + 如果忘记了,请返回应用程序。 + 我明白,我将完全失去对 Tangem Pay 卡及其上所有资金的访问权,且无法挽回 + 发卡失败 + 发生技术故障,请点击下方按钮重试。 + 发生技术故障,请联系技术支持。 + 免费领取您的 Tangem Visa 虚拟卡 + 获取 Tangem Pay + 前往支持页面 + 通常需要最多15分钟 + 设置您的 Tangem 卡 + 正在发放您的卡片 + 该卡通常会在 5 分钟内自动发放。极少数情况下,如果需要人工审核,则可能需要长达 48 小时。 + Tangem Pay + 确认取消 + 您确定要停止 KYC 流程吗?您可以随时返回继续。 + 我们无法验证您的个人资料。如有任何疑问,请联系客服。 + 很抱歉,我们无法验证您的身份。 + KYC 被拒 + KYC正在进行中 + 查看状态 + Tangem Pay 的 KYC 流程正在进行中 + 文件通常会在 5 分钟内自动验证。极少数情况下,如果需要人工审核,则可能需要长达 48 小时。 + 已拒绝 + 隐藏 KYC 块 + 抱歉,我们无法验证 + 您的个人资料。 + 免费领取您的 Tangem Visa 虚拟卡 + 使用 USDC 进行日常支付 + 获取卡片 + 数字卡可与 Apple Pay 和 Google Pay 一起使用 + 随意支配您的资产 + 购买无需支付任何额外费用 + 实际支付金额与所示金额一致 + 将在不透露您的地址和资产的情况下创建一个单独的付款账户 + 无与伦比的隐私保护 + 几分钟内即可获得免费的 Tangem Pay 卡 + 支付账户 + 支付账户未同步 + 无效PIN码:请避免使用连续或重复的密码。 + 我们正在修复技术问题,请稍后再试。 + 服务暂时不可用 + 无法显示详细信息。但刷卡支付功能仍然可用。 + 设置 PIN 码 + 会话已过期 + 恢复访问权限 + 使用 USDC 进行日常支付 + Tangem Pay暂时无法使用。 + Tangem Pay + 点击下方按钮恢复访问权限 + 您的Polygon链上 USDC 余额与您的卡片余额不同,并在购买后 2 个工作日内更新。购物退款的资金不会退还至您的链上余额,也不能提现,但会保留在您的卡片余额中用于购物。 + 请注意 + 您的PIN码 + 授权已被撤销。您的资金仍处于收益模式。如需执行操作,请进入收益模式并重新授予权限。 + 不适用 + 显示二维码 + 为 %s + 之前的授权将被撤销,并颁发新的授权。网络将对每次此类操作收取代币批准费。您将在交易记录中看到一笔金额为零的交易,作为撤销授权的证据。 + 交易金额超过先前授权的金额。\n请更新授权以继续 + 更新权限 + 升级到硬件钱包 + 实时提醒交易、兑换和重要更新。 + 交易提醒 + 抢先体验最新功能和专属优惠。 + 专题报道和新闻更新 + 启用推送通知,即可在资金到账时收到提醒。 + 不要错过任何一笔交易 + 如果您在没有备份的情况下删除此钱包,您将永久失去对您的资金的访问权限。 + 选择您的钱包类型 + 扫描您的 Tangem 卡或指环以恢复卡号或从其他钱包导入卡号。 + 创建硬件钱包 + 想购买 Tangem 钱包吗? + 导入助记词 + 在手机上恢复您的钱包或从其他应用程序导入—方便,但安全性不如 Tangem 卡。 + 选择哪个? + 多部分交易 + 为了顺利完成交易,您的交易将被拆分成多个部分。您需要多次刷卡才能完成交易。 + 交易正在处理中。请多次用卡轻触以完成交易。 + 交易进行中 + 创建 Tangem 钱包 + 开始迁移 + 复制 + 要继续使用您的资金,请根据 Clore 官方指南开始迁移。 + 该网络不支持信息签名 + 无法签署信息。请重试。 + 根据 Clore 的官方文档,所有在 12 月 21 日之前收到的 Clore 代币(ERC-20 代币)都将迁移到 Clore;此日期之后收到的代币则不会迁移。迁移方案即将推出,敬请期待。 + 消息 + 打开索赔通道 + 要继续使用您的 Clore 代币,您必须根据索赔通道上的信息完成代币迁移。 + Clore 网络迁移 + 签署 + 签名 + Clore 网络迁移 + 此交易对不支持互换。请选择其他代币重试。 + 不支持的互换对 + 所需区块链 %s 尚未添加到您的投资组合中。请先添加,然后再进行连接。 + 将区块链添加到投资组合 + 要继续,请将您的 dApp 会话重新连接到所需的区块链网络 %s。 + 区块链网络未连接 + 检查您的区块链连接 + 请求超时 + 该区块链 %s Tangem Wallet 不支持,无法连接。 + 不支持的区块链 + 通过批准,您允许 dApp 或智能合约在未来的交易中使用代币。 + 已经拥有 Tangem 钱包? + 数千种资产 + 顶级硬件钱包 + 快速发货 + 一触即开始 + 无缝且安全 + 无助记词 + 简单易用 + 用 Tangem 创建硬件钱包。像银行卡一样纤薄,像银行保险库一样安全。 + 创建或导入软件钱包 + 在您的手机上创建或导入软件钱包。 + 从手机钱包开始 + 其他方法 + 使用 Tangem 硬件钱包 + 了解更多并购买 + 启用收益模式后,所有未来充值到此地址的资金都将转入 Aave。您仍然可以自由管理您的资金。 + 你的 %s 提供给 Aave + 供应 %1$s %2$s 到 Aave + 批准 + 您的代币授权已被撤销。请重新授权代币以恢复服务功能。 + 需要批准 + 费用将被扣除,您的资产将被重新提供。 + 要继续产生收益,需要获得批准。 + 确认批准 + 您的资金目前已提供给 Aave 协议,但您可以随时对其进行管理。 + 你的 %s 提供给 Aave + 无法加载图表... + 供应 %1$s %2$s 到 Aave。 + 禁用收益模式 + APY %1$s%% + 可用 + 当前年收益率 + 充值贷款时,将从余额中扣除不超过 %1$s 的网络费。 + 目前网络费用过高,无法执行借贷。一旦降至 %1$s 或以下,将立即提供资金。 + 我的资金 + 您的 %1$s 现已部署到 Aave 并产生收益。您持有 %2$s 这些代币代表您的余额,并会自动累积收益。当您充值时,扣除手续费后,资金将提供给 Aave 以产生更多收益。 + 收益模式 + 总收益 + 转至Aave + 探索 Aave + 这是 %s上的当前供应费。实际费用将显示在激活选项卡上。 + 当前费用 + 在扣除交易费后,今后所有 %s 充值都将自动提供给 Aave。 + 以后每次充值都会扣除大约 %1$s (%2$s) 的网络费,但不会超过 %3$s (%4$s) 的限额。 + 如果网络费用超过最高限额,交易将无法完成,直到网络费用降低为止。您可以稍后更改此限额。 + 最高费用 + 最低金额根据当前网络费用计算,确保不超过充值金额的 4%% ,相当于最低 %1$s (%2$s)。 + 最低充值金额 + 充值费用政策 + Tangem 还从产生的收益中提取 15% 服务费。 + 一旦网络费用降低或您的余额达到最低金额要求,您的资金将自动转入 Aave。 + 由于市场活跃,手续费高于平时。您可以立即进行交易,也可以稍后手续费降低时再来查看。 + 高昂的网络费用 + 历史回报 + 启动 %1$s%% 您余额的APY + 您的代币在收益模式下的授权已被撤销。请打开代币以重新授予权限。 + 需要代币批准 + 检查您的网络连接 + 网络费用信息无法获取 + 每次充值都将自动提供给 Aave。 + 您账户上的所有 %1$s 都将自动供应给 Aave。 + 自动供应至Aave + 您可以随时随地发送、兑换或出售您的资金。 + 无锁仓 + 它是如何运作的? + Aave 是一个链上协议,提供非托管流动性市场,使用户能够以可变利率累积收益。 + 去中心化和自托管 + 使用此服务即表示您同意服务提供商的条款\n%1$s 和 %2$s + 连接到 Aave + 启动 %1$s%% APY\非您的余额 + Aave %1$s%% - 可变利率 + 浮动利率 + Aave + 平均值 %s + 去年的收益 + 当前利率始终是浮动的,由 Aave 的链上智能合约根据实时供求关系自动计算。 + 技术支持 + 利率为浮动利率 + 充值后,您的资金将自动转入 Aave 账户,开始产生收益。 %s 将从中扣除交易手续费。 + 供应资产 + 您的 %s 将提供给 Aave ,没有锁仓,并且可以完全访问。 + 请参见充值费用政策 + 您的下一笔充值将自动发送至 Aave。 + 您未来所有的收入 %1$s 存款将自动存入 Aave。 + 活跃 + 暂停 + 禁用收益模式 + 关闭此功能会将您的资产从 Aave 提取,并将其转换回 %s 放入钱包,同时收益累积停止。 + 退出收益模式时,区块链会收取网络费用。 + 禁用收益模式 + 供应 + 供应 APY + APY + 利息自动累积。 + 利息自动累积 + 收益模式 + 启用收益模式 + 收益模式 + 收益模式合约部署 + 已启用收益模式 + %1$s 供应给 Aave + 已禁用收益模式 + %1$s 已从 Aave 提款 + 收益模式已初始化 + 收益模式重新激活 + 供应给 Aave + %1$s 供应给 Aave + 从 Aave提款 + 自动 + 添加 %1$s %2$s 以支付交易网络费。 + 无法覆盖 %s 费用 + 收益模式暂时不可用。请稍后再试。 + 收益模式不可用 + 无法加载图表... diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 75603cdf2e..dd706dc203 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1535,6 +1535,7 @@ Trade too large We would be happy to receive your feedback Tangem Pay is now in beta + Unable to rename card Card frozen Card payment Deposit @@ -1601,10 +1602,13 @@ Share your address or show QR-code Technical issues detected. Please try again later or contact support. Receive unavailable now + Only letters and numbers are allowed. + Invalid characters Reveal Show details Swap any asset in your portfolio for card Card details + Please try again later. Unfreeze Card Come back to the app if you forget it. Your PIN code @@ -1692,6 +1696,10 @@ Staking Service %1$s token in %%image%% %2$s network Token in %%image%% %1$s network + %s network + %1$s in %2$s network + %1$s in %%image%% %2$s + %1$s in %2$s %%image%% The %1$s (%2$s) token is the main currency on the %3$s network and cannot be hidden as long as you have other tokens on this network in the list Unable to hide %s N/A diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 36bed031e2..fb3369069e 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -11,6 +11,9 @@ android { namespace = "com.tangem.features.tokendetails.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} dependencies { /** AndroidX */ @@ -113,4 +116,9 @@ dependencies { implementation(deps.decompose.ext.compose) + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index 035e4511bf..639595b1f4 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -97,7 +97,6 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( TokenDetailsScreen( tokenDetailsUM = tokenDetailsUM, - tokenMarketBlockComponent = tokenMarketBlockComponent, modifier = modifier, ) } else { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt new file mode 100644 index 0000000000..df70f292b1 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt @@ -0,0 +1,73 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.model + +import com.tangem.core.decompose.di.GlobalUiMessageSender +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.tokendetails.impl.R +import javax.inject.Inject + +@ModelScoped +internal class TokenDetailsDialogFactory @Inject constructor( + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, +) { + + fun showConfirmHideToken(currency: CryptoCurrency, onConfirm: () -> Unit) { + uiMessageSender.send( + DialogMessage( + title = resourceReference( + id = R.string.token_details_hide_alert_title, + formatArgs = wrappedList(currency.name), + ), + message = resourceReference(R.string.token_details_hide_alert_message), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.token_details_hide_alert_hide), + isWarning = true, + onClick = onConfirm, + ) + }, + secondActionBuilder = { cancelAction() }, + ), + ) + } + + fun showLinkedTokens(currency: CryptoCurrency) { + uiMessageSender.send( + DialogMessage( + title = resourceReference( + id = R.string.token_details_unable_hide_alert_title, + formatArgs = wrappedList(currency.symbol), + ), + message = resourceReference( + id = R.string.token_details_unable_hide_alert_message, + formatArgs = wrappedList(currency.name, currency.symbol, currency.network.name), + ), + ), + ) + } + + fun showDismissIncompleteTransactionConfirm(onConfirm: () -> Unit) { + uiMessageSender.send( + DialogMessage( + message = resourceReference(R.string.warning_kaspa_unfinished_token_transaction_discard_message), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_yes), + onClick = onConfirm, + ) + }, + secondActionBuilder = { cancelAction() }, + ), + ) + } + + fun showError(text: TextReference) { + uiMessageSender.send(DialogMessage(message = text)) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 46d9a4eb87..f5def6e3a1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -25,6 +25,10 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent import com.tangem.core.decompose.di.GlobalUiMessageSender +import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -32,9 +36,7 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.common.ui.tokens.getUnavailabilityReasonText import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -86,6 +88,7 @@ import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase +import com.tangem.domain.wallets.usecase.GetWalletIconUseCase import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase @@ -98,13 +101,14 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.Token import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState -import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.TokenDetailsExpressStatusFactory +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter @@ -114,7 +118,6 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import com.tangem.utils.extensions.isZero import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -172,6 +175,13 @@ internal class TokenDetailsModel @Inject constructor( private val isXpubDerivedUseCase: IsXpubDerivedUseCase, private val dynamicAddressesRepository: DynamicAddressesRepository, private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val dialogFactory: TokenDetailsDialogFactory, + private val userWalletsListRepository: UserWalletsListRepository, + private val singleAccountListSupplier: SingleAccountListSupplier, + private val getWalletIconUseCase: GetWalletIconUseCase, + private val walletIconUMConverter: WalletIconUMConverter, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val redesignStateController: TokenDetailsStateController, ) : Model(), TokenDetailsClickIntents, ExpressTransactionsClickIntents, @@ -213,11 +223,10 @@ internal class TokenDetailsModel @Inject constructor( userWalletId = userWalletId, ) - private val internalUiState = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency)) - val uiState: StateFlow = internalUiState + val uiState: StateFlow + field = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency)) - private val internalRedesignUiState = MutableStateFlow(createInitialRedesignState()) - val redesignUiState: StateFlow = internalRedesignUiState + val redesignUiState: StateFlow get() = redesignStateController.uiState // region Clore migration // TODO: Remove after Clore migration ends ([REDACTED_TASK_KEY]) @@ -279,7 +288,10 @@ internal class TokenDetailsModel @Inject constructor( } init { + initRedesignState() updateTopBarMenu() + updateRedesignTopBarMenu() + observeRedesignTopBarTitle() initButtons() updateContent() handleBalanceHiding() @@ -329,7 +341,7 @@ internal class TokenDetailsModel @Inject constructor( private fun handleBalanceHiding() { getBalanceHidingSettingsUseCase() .onEach { settings -> - internalUiState.value = stateFactory.getStateWithUpdatedHidden( + uiState.value = stateFactory.getStateWithUpdatedHidden( isBalanceHidden = settings.isBalanceHidden, ) } @@ -345,7 +357,7 @@ internal class TokenDetailsModel @Inject constructor( .distinctUntilChanged() .onEach { state -> sendButtonsEvents(state.states) - internalUiState.value = stateFactory.getManageButtonsState(actions = state.states) + uiState.value = stateFactory.getManageButtonsState(actions = state.states) } .flowOn(dispatchers.main) .launchIn(modelScope) @@ -377,8 +389,8 @@ internal class TokenDetailsModel @Inject constructor( .distinctUntilChanged() .onEach { warnings -> val updatedState = stateFactory.getStateWithNotifications(warnings) - notificationsAnalyticsSender.send(internalUiState.value, updatedState.notifications) - internalUiState.value = updatedState + notificationsAnalyticsSender.send(uiState.value, updatedState.notifications) + uiState.value = updatedState } .launchIn(modelScope) .saveIn(warningsJobHolder) @@ -391,7 +403,7 @@ internal class TokenDetailsModel @Inject constructor( .map { it.status.right() } .distinctUntilChanged() .onEach { maybeCurrencyStatus -> - internalUiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus) + uiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus) maybeCurrencyStatus.onRight { status -> sendOneTimeBalanceLoadedAnalyticsEvent(status) cryptoCurrencyStatus = status @@ -413,7 +425,7 @@ internal class TokenDetailsModel @Inject constructor( .distinctUntilChanged() .onEach { waitForFirstExpressStatusEmmit.value = true } .onEach { expressTxs -> - internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( + uiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( expressTxs = expressTxs, updateBalance = ::updateNetworkToSwapBalance, ) @@ -424,11 +436,11 @@ internal class TokenDetailsModel @Inject constructor( delay = EXPRESS_STATUS_UPDATE_DELAY, task = { runSuspendCatching { - expressStatusFactory.getUpdatedExpressStatuses(internalUiState.value.expressTxs) + expressStatusFactory.getUpdatedExpressStatuses(uiState.value.expressTxs) } }, onSuccess = { updatedTxs -> - internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( + uiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs( updatedTxs, ::updateNetworkToSwapBalance, ) @@ -449,14 +461,14 @@ internal class TokenDetailsModel @Inject constructor( } yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value) .onEach { formatted -> - internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted) + uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted) } .flowOn(dispatchers.main) .launchIn(modelScope) .saveIn(yieldSupplyBalanceJobHolder) } else { yieldSupplyBalanceJobHolder.cancel() - internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance( + uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance( YieldSupplyRewardBalance.empty(), ) } @@ -493,7 +505,7 @@ internal class TokenDetailsModel @Inject constructor( null } - internalUiState.update { state -> + uiState.update { state -> stateFactory.getStakingInfoState( state = state, stakingEntryInfo = stakingEntryInfo, @@ -517,7 +529,7 @@ internal class TokenDetailsModel @Inject constructor( val isSupported = isXPUBSupported() val isDynamicAddressesAvailable = isSupported && isDynamicAddressesAvailable() - internalUiState.value = stateFactory.getStateWithUpdatedMenu( + uiState.value = stateFactory.getStateWithUpdatedMenu( userWallet = userWallet, hasDerivations = hasDerivations, isSupported = isSupported, @@ -890,7 +902,7 @@ internal class TokenDetailsModel @Inject constructor( } override fun onRefreshSwipe(isRefreshing: Boolean) { - internalUiState.value = stateFactory.getRefreshingState() + uiState.value = stateFactory.getRefreshingState() modelScope.launch(dispatchers.main) { listOf( @@ -902,29 +914,29 @@ internal class TokenDetailsModel @Inject constructor( subscribeOnExpressTransactionsUpdates() }, ).awaitAll() - internalUiState.value = stateFactory.getRefreshedState() + uiState.value = stateFactory.getRefreshedState() }.saveIn(refreshStateJobHolder) } override fun onDismissBottomSheet() { - when (val bsContent = internalUiState.value.bottomSheetConfig?.content) { + when (val bsContent = uiState.value.bottomSheetConfig?.content) { is ExpressStatusBottomSheetConfig -> { modelScope.launch(dispatchers.main) { expressStatusFactory.removeTransactionOnBottomSheetClosed(bsContent.value) } } } - internalUiState.value = stateFactory.getStateWithClosedBottomSheet() + uiState.value = stateFactory.getStateWithClosedBottomSheet() } override fun onCloseRentInfoNotification() { - internalUiState.value = stateFactory.getStateWithRemovedRentNotification() + uiState.value = stateFactory.getStateWithRemovedRentNotification() } override fun onExpressTransactionClick(txId: String) { - val expressTxState = internalUiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId } + val expressTxState = uiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId } ?: return - internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState) + uiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState) } override fun onGoToProviderClick(url: String) { @@ -1018,7 +1030,7 @@ internal class TokenDetailsModel @Inject constructor( } }, ifRight = { - internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() + uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() }, ) } @@ -1060,7 +1072,7 @@ internal class TokenDetailsModel @Inject constructor( showErrorDialog(message) } }, - ifRight = { internalUiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() }, + ifRight = { uiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() }, ) } } @@ -1086,7 +1098,7 @@ internal class TokenDetailsModel @Inject constructor( TangemLogger.e("Error: $e") }, ifRight = { - internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() + uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification() }, ) } @@ -1120,13 +1132,13 @@ internal class TokenDetailsModel @Inject constructor( } } }, - ifRight = { internalUiState.value = stateFactory.getStateWithRemovedHederaAssociateNotification() }, + ifRight = { uiState.value = stateFactory.getStateWithRemovedHederaAssociateNotification() }, ) } } override fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) { - internalUiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config) + uiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config) } override fun onConfirmDisposeExpressStatus() { @@ -1134,7 +1146,7 @@ internal class TokenDetailsModel @Inject constructor( } override fun onDisposeExpressStatus() { - val bottomSheetState = internalUiState.value.bottomSheetConfig?.content + val bottomSheetState = uiState.value.bottomSheetConfig?.content if (bottomSheetState is ExpressStatusBottomSheetConfig) { modelScope.launch { expressStatusFactory.removeTransactionOnBottomSheetClosed( @@ -1143,7 +1155,7 @@ internal class TokenDetailsModel @Inject constructor( ) } } - internalUiState.value = stateFactory.getStateWithClosedBottomSheet() + uiState.value = stateFactory.getStateWithClosedBottomSheet() } override fun onYieldInfoClick() { @@ -1194,52 +1206,16 @@ internal class TokenDetailsModel @Inject constructor( } private fun showConfirmHideTokenDialog(currency: CryptoCurrency) { - uiMessageSender.send( - DialogMessage( - title = resourceReference( - id = R.string.token_details_hide_alert_title, - formatArgs = wrappedList(currency.name), - ), - message = resourceReference(R.string.token_details_hide_alert_message), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.token_details_hide_alert_hide), - isWarning = true, - onClick = ::onHideConfirmed, - ) - }, - secondActionBuilder = { cancelAction() }, - ), - ) + dialogFactory.showConfirmHideToken(currency = currency, onConfirm = ::onHideConfirmed) } private fun showLinkedTokensDialog(currency: CryptoCurrency) { - uiMessageSender.send( - DialogMessage( - title = resourceReference( - id = R.string.token_details_unable_hide_alert_title, - formatArgs = wrappedList(currency.symbol), - ), - message = resourceReference( - id = R.string.token_details_unable_hide_alert_message, - formatArgs = wrappedList(currency.name, currency.symbol, currency.network.name), - ), - ), - ) + dialogFactory.showLinkedTokens(currency = currency) } private fun showDismissIncompleteTransactionConfirmDialog() { - uiMessageSender.send( - DialogMessage( - message = resourceReference(R.string.warning_kaspa_unfinished_token_transaction_discard_message), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.common_yes), - onClick = ::onConfirmDismissIncompleteTransactionClick, - ) - }, - secondActionBuilder = { cancelAction() }, - ), + dialogFactory.showDismissIncompleteTransactionConfirm( + onConfirm = ::onConfirmDismissIncompleteTransactionClick, ) } @@ -1262,7 +1238,7 @@ internal class TokenDetailsModel @Inject constructor( } private fun showErrorDialog(text: TextReference) { - uiMessageSender.send(DialogMessage(message = text)) + dialogFactory.showError(text = text) } private fun checkForActionUpdates() { @@ -1402,29 +1378,79 @@ internal class TokenDetailsModel @Inject constructor( // endregion Clore migration - private fun createInitialRedesignState(): TokenDetailsUM { - return TokenDetailsUM( - topAppBarUM = TokenDetailsTopAppBarUM( - title = stringReference(cryptoCurrency.name), - subtitle = stringReference(cryptoCurrency.symbol), - menuItems = persistentListOf(), + private fun updateRedesignTopBarMenu() { + modelScope.launch(dispatchers.main) { + val hasDerivations = networkHasDerivationUseCase( + userWallet = userWallet, + network = cryptoCurrency.network, + ).getOrElse { false } + + val isSupported = isXPUBSupported() + + redesignStateController.update( + UpdateTopBarMenuTransformer( + userWallet = userWallet, + hasDerivations = hasDerivations, + isXPubSupported = isSupported, + onGenerateExtendedKey = ::onGenerateExtendedKey, + onHideClick = ::onHideClick, + ), + ) + } + } + + private fun initRedesignState() { + redesignStateController.update( + InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = ::onBackClick, ), - balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( - actionButtons = persistentListOf(), - tokenBalanceTypeUM = TokenBalanceTypeUM.Single, - currencyIconState = CurrencyIconState.Loading, - ), - marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol), - stakingBlocksState = null, - pullToRefreshConfig = PullToRefreshConfig( - isRefreshing = false, - onRefresh = {}, - ), - isBalanceHidden = false, - isMarketPriceAvailable = false, ) } + private fun observeRedesignTopBarTitle() { + combine( + flow = userWalletsListRepository.userWallets.filterNotNull(), + flow2 = isAccountsModeEnabledUseCase.invoke(), + flow3 = singleAccountListSupplier(userWalletId), + flow4 = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .map { status -> status.account } + .distinctUntilChanged(), + ) { wallets, accountsModeEnabled, accountList, currentAccount -> + val currentWallet = wallets.firstOrNull { it.walletId == userWalletId } ?: userWallet + TopBarTitleInputs( + hasMultipleWallets = wallets.size > 1, + hasMultipleAccounts = accountsModeEnabled && accountList.accounts.size > 1, + walletName = currentWallet.name, + deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(currentWallet)), + account = currentAccount, + ) + } + .distinctUntilChanged() + .onEach { inputs -> + redesignStateController.update( + SetTopBarTitleTransformer( + cryptoCurrency = cryptoCurrency, + hasMultipleWallets = inputs.hasMultipleWallets, + hasMultipleAccounts = inputs.hasMultipleAccounts, + walletName = inputs.walletName, + deviceIconUM = inputs.deviceIconUM, + account = inputs.account, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private data class TopBarTitleInputs( + val hasMultipleWallets: Boolean, + val hasMultipleAccounts: Boolean, + val walletName: String, + val deviceIconUM: DeviceIconUM, + val account: Account.CryptoPortfolio?, + ) + private companion object { const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L const val BASE_DERIVATION_NODE_COUNT = 5 diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt new file mode 100644 index 0000000000..41e505d325 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt @@ -0,0 +1,54 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class TokenDetailsStateController @Inject constructor() { + + val uiState: StateFlow + field = MutableStateFlow(value = getInitialState()) + + val value: TokenDetailsUM get() = uiState.value + + fun update(function: (TokenDetailsUM) -> TokenDetailsUM) { + uiState.update(function = function) + } + + fun update(transformer: Transformer) { + uiState.update(function = transformer::transform) + } + + private fun getInitialState(): TokenDetailsUM { + return TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = ""), + subtitle = TextReference.EMPTY, + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = ""), + stakingBlocksState = null, + pullToRefreshConfig = PullToRefreshConfig( + isRefreshing = false, + onRefresh = {}, + ), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt index 14b55185e8..91ee9dc832 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt @@ -1,12 +1,15 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state -import androidx.compose.runtime.Stable +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList -@Stable +@Immutable internal data class TokenDetailsUM( val topAppBarUM: TokenDetailsTopAppBarUM, val balanceBlockUM: TokenDetailsBalanceBlockUM, @@ -17,8 +20,31 @@ internal data class TokenDetailsUM( val isMarketPriceAvailable: Boolean, ) +@Immutable internal data class TokenDetailsTopAppBarUM( - val title: TextReference, + val titleState: TitleState, val subtitle: TextReference, - val menuItems: ImmutableList, -) \ No newline at end of file + val onBackClick: () -> Unit, + val menuItems: ImmutableList, +) { + @Immutable + sealed interface TitleState { + val tokenName: String + + data class Simple( + override val tokenName: String, + ) : TitleState + + data class WithWallet( + override val tokenName: String, + val walletName: String, + val deviceIconUM: DeviceIconUM, + ) : TitleState + + data class WithAccount( + override val tokenName: String, + val accountName: TextReference, + val accountIconUM: AccountIconUM.CryptoPortfolio, + ) : TitleState + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt new file mode 100644 index 0000000000..64f054639c --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt @@ -0,0 +1,23 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class InitializeWithCryptoCurrencyTransformer( + private val cryptoCurrency: CryptoCurrency, + private val onBackClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy( + topAppBarUM = prevState.topAppBarUM.copy( + titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = cryptoCurrency.name), + subtitle = stringReference(cryptoCurrency.symbol), + onBackClick = onBackClick, + ), + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol), + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformer.kt new file mode 100644 index 0000000000..443e904a62 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformer.kt @@ -0,0 +1,78 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.common.ui.account.toUM +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer +import com.tangem.core.res.R as CoreResR + +internal class SetTopBarTitleTransformer( + private val cryptoCurrency: CryptoCurrency, + private val hasMultipleWallets: Boolean, + private val hasMultipleAccounts: Boolean, + private val walletName: String, + private val deviceIconUM: DeviceIconUM, + private val account: Account.CryptoPortfolio?, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy( + topAppBarUM = prevState.topAppBarUM.copy( + titleState = createTitleState(), + subtitle = createSubtitle(), + ), + ) + + private fun createTitleState(): TitleState { + val tokenName = cryptoCurrency.name + + return when { + hasMultipleAccounts && account != null -> { + val accountNameUM = account.accountName.toUM() + TitleState.WithAccount( + tokenName = tokenName, + accountName = accountNameUM.value, + accountIconUM = AccountIconUM.CryptoPortfolio( + value = account.icon.value, + color = account.icon.color, + ), + ) + } + hasMultipleWallets -> TitleState.WithWallet( + tokenName = tokenName, + walletName = walletName, + deviceIconUM = deviceIconUM, + ) + else -> TitleState.Simple(tokenName = tokenName) + } + } + + private fun createSubtitle(): TextReference { + val networkName = cryptoCurrency.network.name + return when (cryptoCurrency) { + is CryptoCurrency.Token -> { + val standardName = cryptoCurrency.network.standardType + .takeIf { it !is Network.StandardType.Unspecified } + ?.name + if (standardName != null) { + resourceReference( + CoreResR.string.token_details_toolbar_subtitle_standard, + wrappedList(standardName, networkName), + ) + } else { + resourceReference(CoreResR.string.token_details_toolbar_subtitle_network, wrappedList(networkName)) + } + } + is CryptoCurrency.Coin -> { + resourceReference(CoreResR.string.token_details_toolbar_subtitle_network, wrappedList(networkName)) + } + } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt new file mode 100644 index 0000000000..320e28bdce --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt @@ -0,0 +1,51 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.themedColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.features.tokendetails.impl.R +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +internal class UpdateTopBarMenuTransformer( + private val userWallet: UserWallet, + private val hasDerivations: Boolean, + private val isXPubSupported: Boolean, + private val onGenerateExtendedKey: () -> Unit, + private val onHideClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy( + topAppBarUM = prevState.topAppBarUM.copy(menuItems = createMenuItems()), + ) + + private fun createMenuItems() = if (userWallet is UserWallet.Cold && + userWallet.cardTypesResolver.isSingleWalletWithToken() + ) { + persistentListOf() + } else { + buildList { + if (isXPubSupported && hasDerivations) { + add( + TangemDropdownMenuItem( + title = resourceReference(R.string.token_details_generate_xpub), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = onGenerateExtendedKey, + ), + ) + } + add( + TangemDropdownMenuItem( + title = resourceReference(R.string.token_details_hide_token), + textColor = themedColor { TangemTheme.colors.text.warning }, + onClick = onHideClick, + ), + ) + }.toImmutableList() + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 01902e1cab..6a1b96d329 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -1,30 +1,148 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui +import android.content.res.Configuration +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar +import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.themedColor +import com.tangem.core.ui.res.LocalRootBackgroundColor import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM -import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeTint +import kotlinx.collections.immutable.persistentListOf -@Suppress("UnusedParameter") @Composable -internal fun TokenDetailsScreen( - tokenDetailsUM: TokenDetailsUM, - tokenMarketBlockComponent: TokenMarketBlockComponent?, - modifier: Modifier = Modifier, -) { +internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifier = Modifier) { + val topAppBarUM = tokenDetailsUM.topAppBarUM + + // TODO [REDACTED_TASK_KEY] Token Details Make Balance with actions + val balanceBlockHeight = 200.dp + val partialCollapsedHeight = 0.dp + val expandedHeight = balanceBlockHeight + partialCollapsedHeight + + val behavior = rememberTangemExitUntilCollapsedScrollBehavior( + expandedHeight = expandedHeight, + partialCollapsedHeight = partialCollapsedHeight, + ) + Box( modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, ) { - Text( - text = "Token Details Redesign", - style = TangemTheme.typography.h2, - color = TangemTheme.colors.text.primary1, + Box( + modifier = Modifier + .fillMaxSize() + .hazeSourceTangem(zIndex = -2f), + ) { + TangemCollapsingTopBar( + state = behavior.state, + collapsingPart = { + // [REDACTED_TASK_KEY] Token Details Make Balance with actions + Box( + modifier = Modifier + .fillMaxWidth() + .height(balanceBlockHeight), + ) + }, + body = { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(behavior.nestedScrollConnection), + ) { + // TODO [REDACTED_TASK_KEY] Token Details Make Transaction History + } + }, + ) + } + + val rootBackground by LocalRootBackgroundColor.current + val hazeIntensity by animateFloatAsState( + targetValue = (behavior.state.collapsedFraction * 2f).coerceIn(0f, 1f), + label = "TopBarHazeIntensity", + ) + Box( + modifier = Modifier.hazeEffectTangem { + fallbackTint = HazeTint(rootBackground.copy(alpha = hazeIntensity / 2f)) + progressive = HazeProgressive.verticalGradient( + startIntensity = hazeIntensity, + endIntensity = 0f, + preferPerformance = true, + ) + }, + ) { + TokenDetailsTopBar(topAppBarUM = topAppBarUM) + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenDetailsScreen_Preview() { + TangemThemePreviewRedesign { + TokenDetailsScreen( + tokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.WithAccount( + tokenName = "Tether", + accountName = stringReference("Portfolio"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + subtitle = stringReference("ERC-20 in Ethereum network"), + onBackClick = {}, + menuItems = persistentListOf( + TangemDropdownMenuItem( + title = stringReference("Hide Token"), + textColor = themedColor { TangemTheme.colors.text.warning }, + onClick = {}, + ), + ), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = "USDT"), + stakingBlocksState = null, + pullToRefreshConfig = PullToRefreshConfig( + isRefreshing = false, + onRefresh = {}, + ), + isBalanceHidden = false, + isMarketPriceAvailable = true, + ), ) } -} \ No newline at end of file +} +// endregion Preview \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt new file mode 100644 index 0000000000..aaf3a4838f --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsTopBar.kt @@ -0,0 +1,478 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.account.AccountIcon +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu +import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.image.TangemDeviceIcon +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent +import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.extensions.themedColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import kotlinx.collections.immutable.persistentListOf +import com.tangem.core.ui.R as CoreUiR + +@Composable +internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: Modifier = Modifier) { + TangemTopBar( + modifier = modifier.statusBarsPadding(), + startContent = { + TangemTopBarActionContent( + actionUM = TangemTopBarActionUM( + iconRes = CoreUiR.drawable.ic_back_24, + onClick = topAppBarUM.onBackClick, + ghostModeProgress = 1f, + ), + ) + }, + endContent = if (topAppBarUM.menuItems.isNotEmpty()) { + { + var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) } + Box { + TangemTopBarActionContent( + actionUM = TangemTopBarActionUM( + iconRes = CoreUiR.drawable.ic_more_default_24, + onClick = { isDropdownMenuShown = true }, + ghostModeProgress = 1f, + ), + ) + TangemDropdownMenu( + expanded = isDropdownMenuShown, + modifier = Modifier.background(TangemTheme.colors.background.primary), + onDismissRequest = { isDropdownMenuShown = false }, + content = { + topAppBarUM.menuItems.fastForEach { menuItem -> + TangemDropdownItem( + item = menuItem, + dismissParent = { isDropdownMenuShown = false }, + ) + } + }, + ) + } + } + } else { + null + }, + content = { + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = TangemTheme.dimens2.x1), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5), + ) { + TokenDetailsTitle(titleState = topAppBarUM.titleState) + Text( + text = topAppBarUM.subtitle.resolveAnnotatedReference(), + color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.captionMedium12, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + ) +} + +@Composable +private fun TokenDetailsTitle(titleState: TitleState) { + val appearance = TitleAppearance( + style = TangemTheme.typography2.bodySemibold16, + iconSize = TangemTheme.dimens2.x5, + spacing = TangemTheme.dimens2.x1, + ) + + when (titleState) { + is TitleState.Simple -> { + Text( + text = titleState.tokenName, + color = TangemTheme.colors2.text.neutral.primary, + style = appearance.style, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = MIN_TITLE_FONT_SIZE, + maxFontSize = MAX_TITLE_FONT_SIZE, + ), + ) + } + is TitleState.WithWallet -> { + AdaptiveTokenWithSecondaryRow( + tokenName = titleState.tokenName, + secondaryName = titleState.walletName, + template = stringResourceSafe( + id = CoreUiR.string.token_details_toolbar_title_token_in_wallet, + titleState.tokenName, + titleState.walletName, + ), + appearance = appearance, + icon = { + TangemDeviceIcon( + state = titleState.deviceIconUM, + modifier = Modifier.size(appearance.iconSize), + ) + }, + ) + } + is TitleState.WithAccount -> { + val accountNameStr = titleState.accountName.resolveAnnotatedReference().toString() + AdaptiveTokenWithSecondaryRow( + tokenName = titleState.tokenName, + secondaryName = accountNameStr, + template = stringResourceSafe( + id = CoreUiR.string.token_details_toolbar_title_token_in_account, + titleState.tokenName, + accountNameStr, + ), + appearance = appearance, + icon = { + AccountIcon( + name = titleState.accountName, + icon = titleState.accountIconUM, + size = AccountIconSize.ExtraSmall, + ) + }, + ) + } + } +} + +/** + * Adaptive title for [TitleState.WithAccount] / [TitleState.WithWallet]. + * + * Phrase template carries the [IMAGE_PLACEHOLDER] marker — translator decides where + * the icon sits (e.g. "Tether in [⭐] Portfolio" or "Tether in My Wallet [⭐]"). + * RTL is handled by BiDi inside the single [Text]. + * + * Width-driven cascade: + * 1–2. Full phrase as single [Text] with inline icon; [TextOverflow.Ellipsis] + * trims the secondary name tail when needed. + * 3. No meaningful tail left → fall back to `[tokenName] [icon]` in a [Row]; + * icon is a sibling so ellipsis trims only tokenName, never the icon. + * 4–5. tokenName itself doesn't fit → [TextAutoSize] shrinks to + * [MIN_TITLE_FONT_SIZE], then [TextOverflow.Ellipsis] tails. + * + * #1/#2 vs #3 is decided here via [rememberTextMeasurer]; #4/#5 are delegated + * to [TextAutoSize] + [TextOverflow.Ellipsis] in the fallback branch. + */ +@Composable +private fun AdaptiveTokenWithSecondaryRow( + tokenName: String, + secondaryName: String, + template: String, + appearance: TitleAppearance, + icon: @Composable () -> Unit, +) { + val (beforeIcon, afterIcon) = remember(template) { splitTemplate(template) } + val inlineContent = rememberIconInlineContent(appearance.iconSize, icon) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val isFullTextShown = rememberShouldShowFullText( + beforeIcon = beforeIcon, + afterIcon = afterIcon, + secondaryName = secondaryName, + appearance = appearance, + maxWidthPx = constraints.maxWidth, + ) + if (isFullTextShown) { + FullPhraseTitle( + beforeIcon = beforeIcon, + afterIcon = afterIcon, + style = appearance.style, + inlineContent = inlineContent, + ) + } else { + FallbackTokenWithIconTitle( + tokenName = tokenName, + appearance = appearance, + icon = icon, + ) + } + } +} + +private fun splitTemplate(template: String): Pair { + val parts = template.split(IMAGE_PLACEHOLDER, limit = 2) + return if (parts.size == 2) parts[0] to parts[1] else template to "" +} + +@Composable +private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit): Map { + val iconSizeSp = with(LocalDensity.current) { iconSize.toSp() } + val currentIcon by rememberUpdatedState(icon) + return remember(iconSizeSp) { + mapOf( + ICON_INLINE_ID to InlineTextContent( + placeholder = Placeholder( + width = iconSizeSp, + height = iconSizeSp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + children = { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + currentIcon() + } + }, + ), + ) + } +} + +@Composable +private fun rememberShouldShowFullText( + beforeIcon: String, + afterIcon: String, + secondaryName: String, + appearance: TitleAppearance, + maxWidthPx: Int, +): Boolean { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + return remember(beforeIcon, afterIcon, secondaryName, maxWidthPx, appearance, density) { + if (maxWidthPx <= 0) return@remember true + val fullTextWidthPx = measurer + .measure(text = beforeIcon + afterIcon, style = appearance.style, softWrap = false) + .size.width + val secondaryWidthPx = measurer + .measure(text = secondaryName, style = appearance.style, softWrap = false) + .size.width + val staticWidthPx = (fullTextWidthPx - secondaryWidthPx).coerceAtLeast(0) + val iconReservePx = with(density) { + (appearance.iconSize + appearance.spacing * 2).toPx() + }.toInt() + val minSecondaryPx = with(density) { MIN_SECONDARY_NAME_WIDTH.toPx() }.toInt() + staticWidthPx + iconReservePx + minSecondaryPx <= maxWidthPx + } +} + +@Composable +private fun FullPhraseTitle( + beforeIcon: String, + afterIcon: String, + style: TextStyle, + inlineContent: Map, +) { + val fullText = remember(beforeIcon, afterIcon) { + buildAnnotatedString { + append(beforeIcon) + appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER) + append(afterIcon) + } + } + Text( + text = fullText, + inlineContent = inlineContent, + color = TangemTheme.colors2.text.neutral.primary, + style = style, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun FallbackTokenWithIconTitle(tokenName: String, appearance: TitleAppearance, icon: @Composable () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(appearance.spacing, Alignment.CenterHorizontally), + ) { + Text( + text = tokenName, + color = TangemTheme.colors2.text.neutral.primary, + style = appearance.style, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = MIN_TITLE_FONT_SIZE, + maxFontSize = MAX_TITLE_FONT_SIZE, + ), + modifier = Modifier.weight(weight = 1f, fill = false), + ) + icon() + } +} + +@Immutable +private data class TitleAppearance( + val style: TextStyle, + val iconSize: Dp, + val spacing: Dp, +) + +private const val ICON_INLINE_ID = "account_icon" +private const val IMAGE_PLACEHOLDER = "%image%" + +private val MIN_TITLE_FONT_SIZE = 12.sp +private val MAX_TITLE_FONT_SIZE = 16.sp +private val MIN_SECONDARY_NAME_WIDTH = 48.dp + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenDetailsTopBar_Preview( + @PreviewParameter(TokenDetailsTopBarPreviewProvider::class) titleState: TitleState, +) { + TangemThemePreviewRedesign { + TokenDetailsTopBar( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = titleState, + subtitle = stringReference("ERC-20 in Ethereum network"), + onBackClick = {}, + menuItems = persistentListOf( + TangemDropdownMenuItem( + title = stringReference("Hide Token"), + textColor = themedColor { TangemTheme.colors.text.warning }, + onClick = {}, + ), + ), + ), + ) + } +} + +private class TokenDetailsTopBarPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + // === Base title states === + // Simple — 1 wallet, 1 account + TitleState.Simple(tokenName = "Tether"), + // WithWallet — N wallets, 1 account + TitleState.WithWallet( + tokenName = "Tether", + walletName = "Tangem wallet", + deviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ), + ), + // WithAccount — 1 wallet, N accounts + TitleState.WithAccount( + tokenName = "Tether", + accountName = stringReference("Portfolio"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + // === AdaptiveTokenTitleRow cascade cases === + // Cascade #1 — full text fits as is (short token + short wallet) + TitleState.WithWallet( + tokenName = "BTC", + walletName = "Main", + deviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ), + ), + // Cascade #2 — full text doesn't fit, ellipsized tail still meaningful + TitleState.WithWallet( + tokenName = "Tether", + walletName = "My Long Tangem Hardware Wallet", + deviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ), + ), + // Cascade #3 — secondary part too small to be meaningful, drop to token-only + icon + TitleState.WithWallet( + tokenName = "USDCoinWrapped", + walletName = "Super Extra Long Wallet Name That Definitely Wont Fit", + deviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ), + ), + // Cascade #4 — even tokenName alone doesn't fit at full font size: TextAutoSize shrinks it + TitleState.WithAccount( + tokenName = "VeryLongTokenNameThatOverflowsVeryLongTokenNameThatOverflows", + accountName = stringReference("Portfolio"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + // Cascade #5 — even at MIN_TITLE_FONT_SIZE doesn't fit: TextOverflow.Ellipsis tails it + TitleState.Simple( + tokenName = "ExtremelyLongTokenNameThatCannotPossiblyFitEvenAtMinFontSize", + ), + // Account variant — long account name triggers ellipsized tail (#2) + TitleState.WithAccount( + tokenName = "Tether", + accountName = stringReference("My Personal Long Account Name"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + // Account variant — drop secondary, icon-only fallback (#3) + TitleState.WithAccount( + tokenName = "USDCoinWrapped", + accountName = stringReference("Super Extra Long Account Name That Wont Fit Anywhere"), + accountIconUM = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ), + ), + ) +} +// endregion Preview \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt new file mode 100644 index 0000000000..43672cf4c9 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt @@ -0,0 +1,128 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class InitializeWithCryptoCurrencyTransformerTest { + + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { name } returns TOKEN_NAME + every { symbol } returns TOKEN_SYMBOL + } + private val onBackClick: () -> Unit = mockk(relaxed = true) + + @Test + fun `GIVEN crypto currency WHEN transform THEN top bar title is Simple with token name`() { + // GIVEN + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = TOKEN_NAME)) + } + + @Test + fun `GIVEN crypto currency WHEN transform THEN subtitle is token symbol`() { + // GIVEN + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.subtitle).isEqualTo(stringReference(TOKEN_SYMBOL)) + } + + @Test + fun `GIVEN onBackClick callback WHEN top bar onBackClick invoked THEN callback is dispatched`() { + // GIVEN + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(initialState()) + result.topAppBarUM.onBackClick() + + // THEN + verify(exactly = 1) { onBackClick.invoke() } + } + + @Test + fun `GIVEN crypto currency WHEN transform THEN market price loading carries currency symbol`() { + // GIVEN + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.marketPriceBlockState) + .isEqualTo(MarketPriceBlockState.Loading(currencySymbol = TOKEN_SYMBOL)) + } + + @Test + fun `GIVEN any state WHEN transform THEN unrelated fields are preserved`() { + // GIVEN + val state = initialState() + val transformer = InitializeWithCryptoCurrencyTransformer( + cryptoCurrency = cryptoCurrency, + onBackClick = onBackClick, + ) + + // WHEN + val result = transformer.transform(state) + + // THEN — only top bar title/subtitle/onBackClick and marketPriceBlockState are touched + assertThat(result.topAppBarUM.menuItems).isEqualTo(state.topAppBarUM.menuItems) + assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM) + assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = mockk(relaxed = true), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) + + private companion object { + const val TOKEN_NAME = "Tether" + const val TOKEN_SYMBOL = "USDT" + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt new file mode 100644 index 0000000000..7f72295f3a --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt @@ -0,0 +1,204 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import androidx.compose.ui.graphics.Color +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountName +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class SetTopBarTitleTransformerTest { + + private val tokenName = "Tether" + private val walletName = "My Wallet" + private val deviceIconUM: DeviceIconUM = DeviceIconUM.Card( + mainColor = Color.DarkGray, + secondColor = null, + ) + private val cryptoCurrency: CryptoCurrency.Coin = mockk(relaxed = true) { + every { name } returns tokenName + } + + @Test + fun `GIVEN single wallet single account WHEN transform THEN Simple title`() { + // GIVEN + val transformer = createTransformer( + hasMultipleWallets = false, + hasMultipleAccounts = false, + account = null, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = tokenName)) + } + + @Test + fun `GIVEN multiple wallets and single account WHEN transform THEN WithWallet title`() { + // GIVEN + val transformer = createTransformer( + hasMultipleWallets = true, + hasMultipleAccounts = false, + account = null, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val expected = TitleState.WithWallet( + tokenName = tokenName, + walletName = walletName, + deviceIconUM = deviceIconUM, + ) + assertThat(result.topAppBarUM.titleState).isEqualTo(expected) + } + + @Test + fun `GIVEN single wallet and multiple accounts WHEN transform THEN WithAccount title`() { + // GIVEN + val transformer = createTransformer( + hasMultipleWallets = false, + hasMultipleAccounts = true, + account = stubAccount(), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val title = result.topAppBarUM.titleState + assertThat(title).isInstanceOf(TitleState.WithAccount::class.java) + assertThat((title as TitleState.WithAccount).tokenName).isEqualTo(tokenName) + } + + @Test + fun `GIVEN multiple wallets AND multiple accounts WHEN transform THEN WithAccount wins`() { + // GIVEN — design priority: account branch wins over wallet branch + val transformer = createTransformer( + hasMultipleWallets = true, + hasMultipleAccounts = true, + account = stubAccount(), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isInstanceOf(TitleState.WithAccount::class.java) + } + + @Test + fun `GIVEN multiple accounts but null account WHEN transform THEN falls back to wallet branch`() { + // GIVEN — race protection: hasMultipleAccounts=true but account not loaded yet + val transformer = createTransformer( + hasMultipleWallets = true, + hasMultipleAccounts = true, + account = null, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isInstanceOf(TitleState.WithWallet::class.java) + } + + @Test + fun `GIVEN multiple accounts but null account AND single wallet WHEN transform THEN falls back to Simple`() { + // GIVEN + val transformer = createTransformer( + hasMultipleWallets = false, + hasMultipleAccounts = true, + account = null, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = tokenName)) + } + + @Test + fun `GIVEN account with custom icon WHEN transform THEN icon is propagated`() { + // GIVEN + val account = stubAccount( + iconValue = CryptoPortfolioIcon.Icon.Star, + iconColor = CryptoPortfolioIcon.Color.Azure, + ) + val transformer = createTransformer( + hasMultipleWallets = false, + hasMultipleAccounts = true, + account = account, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val title = result.topAppBarUM.titleState as TitleState.WithAccount + val expectedIcon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Star, + color = CryptoPortfolioIcon.Color.Azure, + ) + assertThat(title.accountIconUM).isEqualTo(expectedIcon) + } + + private fun createTransformer( + hasMultipleWallets: Boolean, + hasMultipleAccounts: Boolean, + account: Account.CryptoPortfolio?, + ) = SetTopBarTitleTransformer( + cryptoCurrency = cryptoCurrency, + hasMultipleWallets = hasMultipleWallets, + hasMultipleAccounts = hasMultipleAccounts, + walletName = walletName, + deviceIconUM = deviceIconUM, + account = account, + ) + + private fun stubAccount( + iconValue: CryptoPortfolioIcon.Icon = CryptoPortfolioIcon.Icon.Star, + iconColor: CryptoPortfolioIcon.Color = CryptoPortfolioIcon.Color.Azure, + ): Account.CryptoPortfolio { + val icon: CryptoPortfolioIcon = mockk { + every { value } returns iconValue + every { color } returns iconColor + } + return mockk { + every { accountName } returns AccountName.DefaultMain + every { this@mockk.icon } returns icon + } + } + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = mockk(relaxed = true), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt new file mode 100644 index 0000000000..f5c2d80d1e --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt @@ -0,0 +1,214 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.card.CardTypesResolver +import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class UpdateTopBarMenuTransformerTest { + + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) + private val cardTypesResolver: CardTypesResolver = mockk(relaxed = true) + private val onGenerateExtendedKey: () -> Unit = mockk(relaxed = true) + private val onHideClick: () -> Unit = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + mockkStatic(UserWallet.Cold::cardTypesResolver) + every { coldWallet.cardTypesResolver } returns cardTypesResolver + } + + @AfterEach + fun tearDown() { + unmockkStatic(UserWallet.Cold::cardTypesResolver) + } + + @Test + fun `GIVEN cold wallet AND single wallet with token WHEN transform THEN menu is empty`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns true + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = true, + isXPubSupported = true, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).isEmpty() + } + + @Test + fun `GIVEN cold wallet AND multi-wallet WHEN transform THEN Hide item is the only one`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = false, + isXPubSupported = false, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).hasSize(1) + + result.topAppBarUM.menuItems.single().onClick() + verify(exactly = 1) { onHideClick.invoke() } + verify(exactly = 0) { onGenerateExtendedKey.invoke() } + } + + @Test + fun `GIVEN hot wallet WHEN transform THEN Hide item is shown regardless of single-wallet flag`() { + // GIVEN — flag is read only for cold wallets, so no stub needed for hotWallet + val transformer = createTransformer( + userWallet = hotWallet, + hasDerivations = false, + isXPubSupported = false, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).hasSize(1) + } + + @Test + fun `GIVEN xPub supported AND derivations exist WHEN transform THEN both items are shown`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = true, + isXPubSupported = true, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN — Generate xPub first, Hide token second + assertThat(result.topAppBarUM.menuItems).hasSize(2) + } + + @Test + fun `GIVEN xPub supported but no derivations WHEN transform THEN xPub item is hidden`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = false, + isXPubSupported = true, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).hasSize(1) + } + + @Test + fun `GIVEN derivations exist but xPub unsupported WHEN transform THEN xPub item is hidden`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = true, + isXPubSupported = false, + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.topAppBarUM.menuItems).hasSize(1) + } + + @Test + fun `GIVEN any state WHEN transform THEN unrelated state fields are preserved`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val state = initialState() + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = false, + isXPubSupported = false, + ) + + // WHEN + val result = transformer.transform(state) + + // THEN — only menuItems is touched + assertThat(result.topAppBarUM.titleState).isEqualTo(state.topAppBarUM.titleState) + assertThat(result.topAppBarUM.subtitle).isEqualTo(state.topAppBarUM.subtitle) + assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + } + + @Test + fun `GIVEN callbacks WHEN menu items invoked THEN callbacks are dispatched`() { + // GIVEN + every { cardTypesResolver.isSingleWalletWithToken() } returns false + val transformer = createTransformer( + userWallet = coldWallet, + hasDerivations = true, + isXPubSupported = true, + ) + + // WHEN + val result = transformer.transform(initialState()) + result.topAppBarUM.menuItems.forEach { it.onClick() } + + // THEN + verify(exactly = 1) { onGenerateExtendedKey.invoke() } + verify(exactly = 1) { onHideClick.invoke() } + } + + private fun createTransformer( + userWallet: UserWallet, + hasDerivations: Boolean, + isXPubSupported: Boolean, + ) = UpdateTopBarMenuTransformer( + userWallet = userWallet, + hasDerivations = hasDerivations, + isXPubSupported = isXPubSupported, + onGenerateExtendedKey = onGenerateExtendedKey, + onHideClick = onHideClick, + ) + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = "Tether"), + subtitle = stringReference("ERC-20 in Ethereum network"), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = mockk(relaxed = true), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file From 8966e28e293782172262728b4d8eb90799f42cb7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Apr 2026 18:57:53 +0400 Subject: [PATCH 013/206] Updated on 2026-08-14 --- .../domain/token/MockCryptoCurrencyFactory.kt | 8 +- .../local/preferences/PreferencesDataStore.kt | 2 + .../utils/SwapCurrencyIdMigration.kt | 151 ++++++++++++ .../utils/SwapCurrencyIdMigrationTest.kt | 224 ++++++++++++++++++ .../data/common/network/NetworkFactory.kt | 3 +- .../data/common/network/NetworkFactoryTest.kt | 9 +- .../NetworkStatusDataModelConverter.kt | 11 +- .../SimpleNetworkStatusConverter.kt | 11 +- .../store/DefaultNetworksStatusesStore.kt | 8 +- .../NetworkStatusDataModelConverterTest.kt | 5 +- .../SimpleNetworkStatusConverterTest.kt | 21 +- .../fetcher/CommonNetworkStatusFetcherTest.kt | 10 +- .../qrscanning/Bip321PaymentUriParserTest.kt | 19 +- .../DefaultQrScanningEventsRepositoryTest.kt | 10 +- .../qrscanning/Eip681PaymentUriParserTest.kt | 3 +- .../qrscanning/QrContentClassifierTest.kt | 3 +- .../qrscanning/SolanaPaymentUriParserTest.kt | 11 +- .../qrscanning/TronPaymentUriParserTest.kt | 11 +- .../DefaultCurrencyChecksRepository.kt | 8 +- .../DefaultGaslessTransactionRepository.kt | 8 +- .../MockedGaslessTransactionRepository.kt | 3 +- .../DefaultAllowanceRepositoryTest.kt | 17 +- .../utils/WcNetworksConverter.kt | 5 +- ...ultYieldSupplyTransactionRepositoryTest.kt | 3 +- .../AccountCryptoCurrencyStatusFinder.kt | 20 +- .../tangem/domain/models/network/Network.kt | 7 +- .../staking/model/StakingIntegrationID.kt | 2 +- .../domain/staking/StakingIdFactoryTest.kt | 11 +- .../staking/StakingIntegrationIDTest.kt | 7 +- .../tangem/domain/tokens/mock/MockNetworks.kt | 3 - domain/yield-supply/build.gradle.kts | 1 + .../YieldSupplyGetCurrentFeeUseCase.kt | 4 +- .../supply/YieldSupplyMinAmountUseCaseTest.kt | 3 +- .../YieldSupplyEnterStatusUseCaseTest.kt | 3 +- .../YieldSupplyGetCurrentFeeUseCaseTest.kt | 18 +- .../YieldSupplyGetDustMinAmountUseCaseTest.kt | 3 +- ...YieldSupplyGetRewardsBalanceUseCaseTest.kt | 6 +- .../usecase/YieldSupplyPendingTrackerTest.kt | 1 - .../PreviewCustomTokenSelectorComponent.kt | 1 - .../preview/PreviewManageTokensComponent.kt | 1 - .../PreviewOnboardingManageTokensComponent.kt | 1 - .../model/CustomTokenSelectorModel.kt | 4 +- .../extended/ui/FeeExtendedSelectorContent.kt | 1 - .../speed/ui/FeeSpeedSelectorContent.kt | 1 - .../token/ui/FeeTokenSelectorContent.kt | 1 - .../feeselector/ui/FeeSelectorBlockContent.kt | 1 - ...firmationNotificationsTransformerV2Test.kt | 3 +- ...firmationNotificationsTransformerV2Test.kt | 3 +- .../ui/preview/SwapAmountContentPreview.kt | 1 - .../ExpressStatusBottomSheetStateProvider.kt | 1 - .../YieldSupplyPromoBannerConverterTest.kt | 25 +- .../DefaultPromoDeeplinkHandlerTest.kt | 9 +- .../wallet/qr/QrContentClassifierTest.kt | 3 +- .../tangem/blockchainsdk/utils/NetworkExt.kt | 7 +- .../com/tangem/lib/crypto/BlockchainUtils.kt | 48 ++-- 55 files changed, 572 insertions(+), 192 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigration.kt create mode 100644 core/datasource/src/test/kotlin/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigrationTest.kt diff --git a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt index d5f73cbee0..79aea5c82e 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt @@ -55,8 +55,7 @@ class MockCryptoCurrencyFactory(private val userWallet: UserWallet.Cold = defaul ) val network = Network( - id = Network.ID(blockchain.id, derivationPath), - backendId = blockchain.toNetworkId(), + id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath), name = blockchain.fullName, isTestnet = blockchain.isTestnet(), derivationPath = derivationPath, @@ -85,12 +84,11 @@ class MockCryptoCurrencyFactory(private val userWallet: UserWallet.Cold = defaul return CryptoCurrency.Token( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId(blockchain.id), + body = CryptoCurrency.ID.Body.NetworkId(blockchain.toNetworkId()), suffix = CryptoCurrency.ID.Suffix.RawID(blockchain.id), ), network = Network( - id = Network.ID(value = blockchain.id, derivationPath), - backendId = "NEVER-MIND", + id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath), name = blockchain.fullName, currencySymbol = "NEVER-MIND", derivationPath = derivationPath, diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt index db9df71d9d..49fc4000a4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesDataStore.kt @@ -16,6 +16,7 @@ import com.tangem.datasource.local.preferences.PreferencesKeys.IS_WALLET_SWAP_PR import com.tangem.datasource.local.preferences.PreferencesKeys.SHOULD_SHOW_RING_PROMO_KEY import com.tangem.datasource.local.preferences.utils.CleanupKeyMigration import com.tangem.datasource.local.preferences.utils.SharedPreferencesKeyMigration +import com.tangem.datasource.local.preferences.utils.SwapCurrencyIdMigration import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.logging.TangemLogger @@ -80,6 +81,7 @@ internal object PreferencesDataStore { legacyKeyName = LEGACY_DEFAULT_KEY_NAME, keyName = PreferencesKeys.BALANCE_HIDING_SETTINGS_KEY.name, ), + SwapCurrencyIdMigration(), CleanupKeyMigration(key = APP_LOGS_KEY), CleanupKeyMigration(key = IS_WALLET_SWAP_PROMO_OKX_SHOW_KEY), CleanupKeyMigration(key = IS_TOKEN_SWAP_PROMO_OKX_SHOW_KEY), diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigration.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigration.kt new file mode 100644 index 0000000000..aa573c9fae --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigration.kt @@ -0,0 +1,151 @@ +package com.tangem.datasource.local.preferences.utils + +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.Preferences +import com.tangem.datasource.local.preferences.PreferencesKeys + +/** + * Migrates cached CryptoCurrency.ID strings from old blockchain.id format to new networkId format. + * + * After refactoring, Network.rawId stores backendId values (e.g. "ethereum") instead of + * blockchain.id values (e.g. "ETH"). CryptoCurrency.ID body contains this value, so cached IDs + * like "coin⟨ETH⟩ethereum" must become "coin⟨ethereum⟩ethereum". + * + * Affected DataStore keys: [PreferencesKeys.SWAP_TRANSACTIONS_KEY], + * [PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY]. + */ +internal class SwapCurrencyIdMigration : DataMigration { + + override suspend fun shouldMigrate(currentData: Preferences): Boolean { + return currentData.contains(PreferencesKeys.SWAP_TRANSACTIONS_KEY) || + currentData.contains(PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY) + } + + override suspend fun migrate(currentData: Preferences): Preferences { + val mutablePrefs = currentData.toMutablePreferences() + + currentData[PreferencesKeys.SWAP_TRANSACTIONS_KEY]?.let { json -> + mutablePrefs[PreferencesKeys.SWAP_TRANSACTIONS_KEY] = migrateJson(json) + } + + currentData[PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY]?.let { json -> + mutablePrefs[PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY] = migrateJson(json) + } + + return mutablePrefs.toPreferences() + } + + override suspend fun cleanUp() { + // nothing to clean up + } + + /** + * Replaces old blockchain.id values with networkId values inside CryptoCurrency.ID strings + * found anywhere in the JSON. Works by finding all `⟨oldId⟩` and `⟨oldId→` patterns and + * replacing the old ID with the new one. + */ + private fun migrateJson(json: String): String { + var result = json + + for ((oldId, newId) in BLOCKCHAIN_ID_TO_NETWORK_ID) { + // Body without derivation path: ⟨oldId⟩ → ⟨newId⟩ + result = result.replace("$BODY_START$oldId$BODY_END", "$BODY_START$newId$BODY_END") + // Body with derivation path: ⟨oldId→ → ⟨newId→ + result = result.replace( + "$BODY_START$oldId$DERIVATION_DELIMITER", + "$BODY_START$newId$DERIVATION_DELIMITER", + ) + } + + return result + } + + private companion object { + const val BODY_START = '\u27E8' // ⟨ + const val BODY_END = '\u27E9' // ⟩ + const val DERIVATION_DELIMITER = '\u2192' // → + + /** Mapping of old blockchain.id → new networkId (only entries where values differ). */ + val BLOCKCHAIN_ID_TO_NETWORK_ID = mapOf( + "ARBITRUM-ONE" to "arbitrum-one", + "ARBITRUM/test" to "arbitrum-one/test", + "AVALANCHE" to "avalanche", + "AVALANCHE/test" to "avalanche/test", + "BINANCE" to "binancecoin", + "BINANCE/test" to "binancecoin/test", + "BSC" to "binance-smart-chain", + "BSC/test" to "binance-smart-chain/test", + "BTC" to "bitcoin", + "BTC/test" to "bitcoin/test", + "BCH" to "bitcoin-cash", + "BCH/test" to "bitcoin-cash/test", + "CARDANO-S" to "cardano", + "DOGE" to "dogecoin", + "DUC" to "ducatus", + "ETH" to "ethereum", + "ETH/test" to "ethereum/test", + "ETC" to "ethereum-classic", + "ETC/test" to "ethereum-classic/test", + "ETH-Pow" to "ethereum-pow-iou", + "ETH-Pow/test" to "ethereum-pow-iou/test", + "FTM" to "fantom", + "FTM/test" to "fantom/test", + "GNO" to "xdai", + "KAS" to "kaspa", + "KAS/test" to "kaspa/test", + "KAVA" to "kava", + "KAVA/test" to "kava/test", + "Kusama" to "kusama", + "LTC" to "litecoin", + "NEAR" to "near-protocol", + "NEAR/test" to "near-protocol/test", + "NEXA" to "nexa", + "NEXA/test" to "nexa/test", + "OPTIMISM" to "optimistic-ethereum", + "Polkadot" to "polkadot", + "POLYGON" to "polygon-pos", + "POLYGON/test" to "polygon-pos/test", + "RSK" to "rootstock", + "SOLANA" to "solana", + "SOLANA/test" to "solana/test", + "TELOS" to "telos", + "TELOS/test" to "telos/test", + "The-Open-Network" to "the-open-network", + "The-Open-Network/test" to "the-open-network/test", + "TRON" to "tron", + "TRON/test" to "tron/test", + "XLM" to "stellar", + "XLM/test" to "stellar/test", + "XRP" to "xrp", + "XTZ" to "tezos", + "DASH" to "dash", + "xdc" to "xdc-network", + "xdc/test" to "xdc-network/test", + "hedera" to "hedera-hashgraph", + "hedera/test" to "hedera-hashgraph/test", + "areon" to "areon-network", + "areon/test" to "areon-network/test", + "pls" to "pulsechain", + "pls/test" to "pulsechain/test", + "zkSyncEra" to "zksync", + "zkSyncEra/test" to "zksync/test", + "polygonZkEVM" to "polygon-zkevm", + "polygonZkEVM/test" to "polygon-zkevm/test", + "flare" to "flare-network", + "flare/test" to "flare-network/test", + "playa3ull" to "playa3ull-games", + "sei" to "sei-network", + "sei/test" to "sei-network/test", + "casper" to "casper-network", + "casper/test" to "casper-network/test", + "odyssey" to "dione", + "odyssey/test" to "dione/test", + "hyperliquid" to "hyperevm", + "hyperliquid/test" to "hyperevm/test", + "quai" to "quai-network", + "quai/test" to "quai-network/test", + "manta/test" to "manta-pacific/test", + "dischain" to "ethereumfair", + ) + } +} \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigrationTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigrationTest.kt new file mode 100644 index 0000000000..d6e94efd26 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/preferences/utils/SwapCurrencyIdMigrationTest.kt @@ -0,0 +1,224 @@ +package com.tangem.datasource.local.preferences.utils + +import androidx.datastore.preferences.core.mutablePreferencesOf +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.local.preferences.PreferencesKeys +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class SwapCurrencyIdMigrationTest { + + private val migration = SwapCurrencyIdMigration() + + // region shouldMigrate + + @Test + fun `shouldMigrate returns true when swap transactions key exists`() = runTest { + val prefs = mutablePreferencesOf(PreferencesKeys.SWAP_TRANSACTIONS_KEY to "[]") + + assertThat(migration.shouldMigrate(prefs)).isTrue() + } + + @Test + fun `shouldMigrate returns true when last swapped currency key exists`() = runTest { + val prefs = mutablePreferencesOf(PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to "[]") + + assertThat(migration.shouldMigrate(prefs)).isTrue() + } + + @Test + fun `shouldMigrate returns true when both keys exist`() = runTest { + val prefs = mutablePreferencesOf( + PreferencesKeys.SWAP_TRANSACTIONS_KEY to "[]", + PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to "[]", + ) + + assertThat(migration.shouldMigrate(prefs)).isTrue() + } + + @Test + fun `shouldMigrate returns false when no keys exist`() = runTest { + val prefs = mutablePreferencesOf() + + assertThat(migration.shouldMigrate(prefs)).isFalse() + } + + // endregion + + // region migrate — coin IDs without derivation path + + @Test + fun `migrates simple coin ID - ETH to ethereum`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}ETH${BE}ethereum")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}ethereum${BE}ethereum")) + } + + @Test + fun `migrates simple coin ID - BTC to bitcoin`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}BTC${BE}bitcoin")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}bitcoin${BE}bitcoin")) + } + + @Test + fun `migrates BSC to binance-smart-chain`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}BSC${BE}binancecoin")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}binance-smart-chain${BE}binancecoin")) + } + + // endregion + + // region migrate — coin IDs with derivation path + + @Test + fun `migrates coin ID with derivation path`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}ETH${DP}12367123${BE}ethereum")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}ethereum${DP}12367123${BE}ethereum")) + } + + @Test + fun `migrates POLYGON with derivation path`() = runTest { + val result = migrateLastSwapped(currencyIdJson("coin${BS}POLYGON${DP}99999${BE}polygon-pos")) + + assertThat(result).isEqualTo(currencyIdJson("coin${BS}polygon-pos${DP}99999${BE}polygon-pos")) + } + + // endregion + + // region migrate — token IDs + + @Test + fun `migrates token ID with contract address`() = runTest { + val result = migrateLastSwapped(currencyIdJson("token${BS}ETH${BE}usdt${CA}0xdAC17")) + + assertThat(result).isEqualTo(currencyIdJson("token${BS}ethereum${BE}usdt${CA}0xdAC17")) + } + + @Test + fun `migrates token ID with derivation path and contract address`() = runTest { + val result = migrateLastSwapped( + currencyIdJson("token${BS}ETH${DP}12345${BE}usdt${CA}0xdAC17"), + ) + + assertThat(result).isEqualTo( + currencyIdJson("token${BS}ethereum${DP}12345${BE}usdt${CA}0xdAC17"), + ) + } + + // endregion + + // region migrate — swap transactions (both from and to IDs) + + @Test + fun `migrates both fromCryptoCurrencyId and toCryptoCurrencyId`() = runTest { + val from = "coin${BS}ETH${BE}ethereum" + val to = "coin${BS}BTC${BE}bitcoin" + val oldJson = "[{\"fromCryptoCurrencyId\":\"$from\",\"toCryptoCurrencyId\":\"$to\"}]" + + val expectedFrom = "coin${BS}ethereum${BE}ethereum" + val expectedTo = "coin${BS}bitcoin${BE}bitcoin" + val expected = "[{\"fromCryptoCurrencyId\":\"$expectedFrom\",\"toCryptoCurrencyId\":\"$expectedTo\"}]" + + val result = migrateSwapTransactions(oldJson) + + assertThat(result).isEqualTo(expected) + } + + // endregion + + // region migrate — no-op cases + + @Test + fun `does not modify already migrated IDs`() = runTest { + val json = currencyIdJson("coin${BS}ethereum${BE}ethereum") + + val result = migrateLastSwapped(json) + + assertThat(result).isEqualTo(json) + } + + @Test + fun `does not modify IDs where blockchain id equals networkId`() = runTest { + val json = currencyIdJson("coin${BS}cosmos${BE}cosmos") + + val result = migrateLastSwapped(json) + + assertThat(result).isEqualTo(json) + } + + @Test + fun `does not modify empty list`() = runTest { + val result = migrateLastSwapped("[]") + + assertThat(result).isEqualTo("[]") + } + + // endregion + + // region migrate — multiple entries + + @Test + fun `migrates multiple entries in list`() = runTest { + val old1 = currencyIdValue("coin${BS}ETH${BE}ethereum") + val old2 = currencyIdValue("coin${BS}TRON${BE}tron") + val oldJson = "[$old1,$old2]" + + val new1 = currencyIdValue("coin${BS}ethereum${BE}ethereum") + val new2 = currencyIdValue("coin${BS}tron${BE}tron") + val expected = "[$new1,$new2]" + + val result = migrateLastSwapped(oldJson) + + assertThat(result).isEqualTo(expected) + } + + // endregion + + // region migrate — preserves unrelated keys + + @Test + fun `preserves unrelated preference keys`() = runTest { + val unrelatedKey = PreferencesKeys.BALANCE_HIDING_SETTINGS_KEY + val unrelatedValue = "some_value" + val prefs = mutablePreferencesOf( + PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to currencyIdJson("coin${BS}ETH${BE}ethereum"), + unrelatedKey to unrelatedValue, + ) + + val result = migration.migrate(prefs) + + assertThat(result[unrelatedKey]).isEqualTo(unrelatedValue) + } + + // endregion + + // region helpers + + private suspend fun migrateLastSwapped(json: String): String? { + val prefs = mutablePreferencesOf(PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY to json) + val result = migration.migrate(prefs) + return result[PreferencesKeys.LAST_SWAPPED_CRYPTOCURRENCY_ID_KEY] + } + + private suspend fun migrateSwapTransactions(json: String): String? { + val prefs = mutablePreferencesOf(PreferencesKeys.SWAP_TRANSACTIONS_KEY to json) + val result = migration.migrate(prefs) + return result[PreferencesKeys.SWAP_TRANSACTIONS_KEY] + } + + private fun currencyIdJson(id: String): String = "[${currencyIdValue(id)}]" + + private fun currencyIdValue(id: String): String = "{\"cryptoCurrencyId\":\"$id\"}" + + private companion object { + const val BS = '\u27E8' // ⟨ body start + const val BE = '\u27E9' // ⟩ body end + const val DP = '\u2192' // → derivation path delimiter + const val CA = '\u2693' // ⚓ contract address delimiter + } + + // endregion +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt index 97ba053720..4d9c4d9cdc 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt @@ -136,8 +136,7 @@ class NetworkFactory @Inject constructor( return runCatching { Network( - id = Network.ID(value = blockchain.id, derivationPath = derivationPath), - backendId = blockchain.toNetworkId(), + id = Network.ID(value = blockchain.toNetworkId(), derivationPath = derivationPath), name = blockchain.fullName, isTestnet = blockchain.isTestnet(), derivationPath = derivationPath, diff --git a/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt b/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt index e0fc158abe..cfc2969751 100644 --- a/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt +++ b/data/common/src/test/kotlin/com/tangem/data/common/network/NetworkFactoryTest.kt @@ -3,6 +3,7 @@ package com.tangem.data.common.network import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.common.test.domain.card.MockScanResponseFactory import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.test.domain.wallet.MockUserWalletFactory @@ -187,7 +188,7 @@ class NetworkFactoryTest { userWallet = userWallet, expected = MockCryptoCurrencyFactory().ethereum.network.copy( id = Network.ID( - value = Blockchain.Ethereum.id, + value = Blockchain.Ethereum.toNetworkId(), derivationPath = expectedDerivationPath, ), derivationPath = expectedDerivationPath, @@ -201,12 +202,12 @@ class NetworkFactoryTest { derivationPath: Network.DerivationPath, ): CreateTestModel.Second { return CreateTestModel.Second( - networkId = Network.ID(value = Blockchain.Ethereum.id, derivationPath = derivationPath), + networkId = Network.ID(value = Blockchain.Ethereum.toNetworkId(), derivationPath = derivationPath), derivationPath = derivationPath, userWallet = userWallet, expected = MockCryptoCurrencyFactory().ethereum.network.copy( id = Network.ID( - value = Blockchain.Ethereum.id, + value = Blockchain.Ethereum.toNetworkId(), derivationPath = derivationPath, ), derivationPath = derivationPath, @@ -227,7 +228,7 @@ class NetworkFactoryTest { derivationStyleProvider = derivationStyleProvider, canHandleTokens = canHandleTokens, expected = MockCryptoCurrencyFactory().ethereum.network.copy( - id = Network.ID(value = Blockchain.Ethereum.id, derivationPath = expectedDerivationPath), + id = Network.ID(value = Blockchain.Ethereum.toNetworkId(), derivationPath = expectedDerivationPath), derivationPath = expectedDerivationPath, canHandleTokens = canHandleTokens, ), diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt index d6878c7f82..d876b01234 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt @@ -1,5 +1,6 @@ package com.tangem.data.networks.converters +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.network.NetworkStatus import com.tangem.utils.converter.Converter @@ -15,17 +16,18 @@ internal object NetworkStatusDataModelConverter : Converter { val address = NetworkAddressConverter.convertBack(value = status.address) + val blockchainId = value.network.toBlockchain().id val amountsConverter = NetworkAmountsConverter( - rawNetworkId = value.network.rawId, + rawNetworkId = blockchainId, derivationPath = value.network.derivationPath, ) val yieldSupplyStatusConverter = NetworkYieldSupplyStatusConverter( - rawNetworkId = value.network.rawId, + rawNetworkId = blockchainId, derivationPath = value.network.derivationPath, ) NetworkStatusDM.Verified( - networkId = NetworkStatusDM.ID(value = value.network.rawId), + networkId = NetworkStatusDM.ID(value = blockchainId), derivationPath = NetworkDerivationPathConverter.convertBack(value = value.network.derivationPath), selectedAddress = address.selectedAddress, availableAddresses = address.addresses, @@ -35,9 +37,10 @@ internal object NetworkStatusDataModelConverter : Converter { val address = NetworkAddressConverter.convertBack(value = status.address) + val blockchainId = value.network.toBlockchain().id NetworkStatusDM.NoAccount( - networkId = NetworkStatusDM.ID(value = value.network.rawId), + networkId = NetworkStatusDM.ID(value = blockchainId), derivationPath = NetworkDerivationPathConverter.convertBack(value = value.network.derivationPath), selectedAddress = address.selectedAddress, availableAddresses = address.addresses, diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt index 287e82b3fa..b82bfb9455 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt @@ -1,5 +1,7 @@ package com.tangem.data.networks.converters +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.data.networks.models.SimpleNetworkStatus import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.domain.models.StatusSource @@ -23,14 +25,15 @@ internal object SimpleNetworkStatusConverter : Converter> * Default implementation of [NetworksStatusesStore] * * @param context context + * @param scope app coroutine scope * @property runtimeStore runtime store * @property persistenceDataStore persistence store - * @param dispatchers dispatchers */ internal class DefaultNetworksStatusesStore( context: Context, + scope: AppCoroutineScope, private val runtimeStore: RuntimeSharedStore, private val persistenceDataStore: DataStore, - private val scope: AppCoroutineScope, ) : NetworksStatusesStore { init { @@ -112,7 +113,8 @@ internal class DefaultNetworksStatusesStore( storedStatuses.toMutableMap().apply { val updatedValues = this[userWalletId.stringValue].orEmpty().filterNot { networks.any { network -> - it.networkId.value == network.rawId && it.derivationPath.value == network.derivationPath.value + it.networkId.value == network.toBlockchain().id && + it.derivationPath.value == network.derivationPath.value } } diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt index 97265f47a1..b995196d80 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.datasource.local.network.entity.NetworkStatusDM import com.tangem.datasource.local.network.entity.NetworkStatusDM.* @@ -74,7 +75,7 @@ internal class NetworkStatusDataModelConverterTest { ), ), expected = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "", type = DerivationPath.Type.NONE, @@ -119,7 +120,7 @@ internal class NetworkStatusDataModelConverterTest { ), ), expected = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "", type = DerivationPath.Type.NONE, diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt index 1b57d19996..a7413a891e 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt @@ -1,6 +1,7 @@ package com.tangem.data.networks.converters import com.google.common.truth.Truth +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.data.networks.models.SimpleNetworkStatus import com.tangem.datasource.local.network.entity.NetworkStatusDM @@ -48,7 +49,7 @@ internal class SimpleNetworkStatusConverterTest { // region Verified ConvertModel( value = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -74,7 +75,7 @@ internal class SimpleNetworkStatusConverterTest { ), expected = SimpleNetworkStatus( id = Network.ID( - value = network.rawId, + value = network.backendId, derivationPath = Network.DerivationPath.Card("card"), ), value = NetworkStatus.Verified( @@ -116,7 +117,7 @@ internal class SimpleNetworkStatusConverterTest { // region NoAccount ConvertModel( value = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -137,7 +138,7 @@ internal class SimpleNetworkStatusConverterTest { ), expected = SimpleNetworkStatus( id = Network.ID( - value = network.rawId, + value = network.backendId, derivationPath = Network.DerivationPath.Card("card"), ), value = NetworkStatus.NoAccount( @@ -168,7 +169,7 @@ internal class SimpleNetworkStatusConverterTest { // region Error ConvertModel( value = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -189,7 +190,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -205,7 +206,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = Verified( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -230,7 +231,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -255,7 +256,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, @@ -276,7 +277,7 @@ internal class SimpleNetworkStatusConverterTest { ), ConvertModel( value = NoAccount( - networkId = ID(network.rawId), + networkId = ID(network.toBlockchain().id), derivationPath = DerivationPath( value = "card", type = DerivationPath.Type.CARD, diff --git a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt index e6f9bf012d..a36840920e 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcherTest.kt @@ -52,7 +52,7 @@ internal class CommonNetworkStatusFetcherTest { val userWalletId = UserWalletId("011") val network = cryptoCurrencyFactory.ethereum.network val extraTokens = setOf( - cryptoCurrencyFactory.createToken(Blockchain.Ethereum) as CryptoCurrency.Token, + cryptoCurrencyFactory.createToken(Blockchain.Ethereum), ) val updateException = IllegalStateException() @@ -80,7 +80,7 @@ internal class CommonNetworkStatusFetcherTest { val userWalletId = UserWalletId("011") val network = cryptoCurrencyFactory.ethereum.network val extraTokens = setOf( - cryptoCurrencyFactory.createToken(Blockchain.Ethereum) as CryptoCurrency.Token, + cryptoCurrencyFactory.createToken(Blockchain.Ethereum), ) val updateResult = model.updateResult val status = model.status @@ -143,15 +143,15 @@ internal class CommonNetworkStatusFetcherTest { it.copy( amounts = mapOf( CryptoCurrency.ID.fromValue( - value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND", + value = "token⟨ethereum⟩NEVER-MIND⚓NEVER-MIND", ) to NetworkStatus.Amount.NotFound, ), pendingTransactions = mapOf( - CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND") to emptySet(), + CryptoCurrency.ID.fromValue(value = "token⟨ethereum⟩NEVER-MIND⚓NEVER-MIND") to emptySet(), ), yieldSupplyStatuses = mapOf( CryptoCurrency.ID.fromValue( - value = "token⟨ETH⟩NEVER-MIND⚓NEVER-MIND", + value = "token⟨ethereum⟩NEVER-MIND⚓NEVER-MIND", ) to null, ), ) diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt index cac610a9e7..19478a403d 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Bip321PaymentUriParserTest.kt @@ -176,7 +176,7 @@ internal class Bip321PaymentUriParserTest { @Test fun `includes tokens on matching network`() { - val btcToken = buildToken("BTC", "RUNE", "contractAddr") + val btcToken = buildToken("bitcoin", "RUNE", "contractAddr") val result = parser.parse( qrCode = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.01", @@ -263,7 +263,13 @@ internal class Bip321PaymentUriParserTest { @Test fun `memo on network with memo support is not unsupported`() { - val xrpCoin = buildCoin("XRP", "XRP", "XRP", decimals = 6, extrasType = Network.TransactionExtrasType.DESTINATION_TAG) + val xrpCoin = buildCoin( + rawNetworkId = "xrp", + name = "XRP", + symbol = "XRP", + decimals = 6, + extrasType = Network.TransactionExtrasType.DESTINATION_TAG, + ) val result = parser.parse( qrCode = "ripple:rAddress?dt=12345", @@ -299,9 +305,9 @@ internal class Bip321PaymentUriParserTest { } } - private val bitcoinCoin = buildCoin("BTC", "Bitcoin", "BTC", decimals = 8) - private val litecoinCoin = buildCoin("LTC", "Litecoin", "LTC", decimals = 8) - private val dogecoinCoin = buildCoin("DOGE", "Dogecoin", "DOGE", decimals = 8) + private val bitcoinCoin = buildCoin("bitcoin", "Bitcoin", "BTC", decimals = 8) + private val litecoinCoin = buildCoin("litecoin", "Litecoin", "LTC", decimals = 8) + private val dogecoinCoin = buildCoin("dogecoin", "Dogecoin", "DOGE", decimals = 8) private fun buildCoin( rawNetworkId: String, @@ -349,8 +355,7 @@ internal class Bip321PaymentUriParserTest { extrasType: Network.TransactionExtrasType = Network.TransactionExtrasType.NONE, ): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = name, currencySymbol = symbol, derivationPath = Network.DerivationPath.None, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt index 3aecfcfbfa..f904a7a32b 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt @@ -1,7 +1,6 @@ package com.tangem.data.qrscanning import com.google.common.truth.Truth -import com.tangem.blockchain.common.Blockchain import com.tangem.data.qrscanning.parser.QrContentClassifierParser import com.tangem.data.qrscanning.repository.DefaultQrScanningEventsRepository import com.tangem.domain.models.currency.CryptoCurrency @@ -78,7 +77,8 @@ internal class DefaultQrScanningEventsRepositoryTest { @Test fun testBip021() { - every { network.id.rawId.value } returns Blockchain.Bitcoin.id + every { network.id } returns Network.ID(value = "bitcoin", derivationPath = Network.DerivationPath.None) + every { network.backendId } returns "bitcoin" positiveCase( "$schema1:$address1", QrResult(address = address1), @@ -128,7 +128,8 @@ internal class DefaultQrScanningEventsRepositoryTest { @Test fun testErc681Coin() { - every { network.id.rawId.value } returns Blockchain.Ethereum.id + every { network.id } returns Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) + every { network.backendId } returns "ethereum" positiveCase( address2, QrResult(address = address2), @@ -183,7 +184,8 @@ internal class DefaultQrScanningEventsRepositoryTest { @Test fun testErc681Token() { - every { network.id.rawId.value } returns Blockchain.Ethereum.id + every { network.id } returns Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) + every { network.backendId } returns "ethereum" positiveCase( address2, QrResult(address = address2), diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt index a98cbe0026..7dba3a75fe 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/Eip681PaymentUriParserTest.kt @@ -382,8 +382,7 @@ internal class Eip681PaymentUriParserTest { private fun buildNetwork(rawNetworkId: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = Network.DerivationPath.None, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt index af88722a8d..d1259b8904 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/QrContentClassifierTest.kt @@ -233,8 +233,7 @@ internal class QrContentClassifierTest { private fun buildNetwork(rawNetworkId: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = Network.DerivationPath.None, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt index 068663caed..4c692cc027 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/SolanaPaymentUriParserTest.kt @@ -198,13 +198,13 @@ internal class SolanaPaymentUriParserTest { } } - private val solanaNetwork = buildNetwork("SOLANA", "Solana", "SOL") + private val solanaNetwork = buildNetwork("solana", "Solana", "SOL") private val solanaCoin = CryptoCurrency.Coin( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId("SOLANA"), - suffix = CryptoCurrency.ID.Suffix.RawID("SOLANA"), + body = CryptoCurrency.ID.Body.NetworkId("solana"), + suffix = CryptoCurrency.ID.Suffix.RawID("solana"), ), network = solanaNetwork, name = "Solana", @@ -217,7 +217,7 @@ internal class SolanaPaymentUriParserTest { private val usdcToken = CryptoCurrency.Token( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId("SOLANA"), + body = CryptoCurrency.ID.Body.NetworkId("solana"), suffix = CryptoCurrency.ID.Suffix.RawID("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), ), network = solanaNetwork, @@ -231,8 +231,7 @@ internal class SolanaPaymentUriParserTest { private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = name, currencySymbol = symbol, derivationPath = Network.DerivationPath.None, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt index 8c07742046..64bab29d5c 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/TronPaymentUriParserTest.kt @@ -238,13 +238,13 @@ internal class TronPaymentUriParserTest { } } - private val tronNetwork = buildNetwork("TRON", "Tron", "TRX") + private val tronNetwork = buildNetwork("tron", "Tron", "TRX") private val tronCoin = CryptoCurrency.Coin( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId("TRON"), - suffix = CryptoCurrency.ID.Suffix.RawID("TRON"), + body = CryptoCurrency.ID.Body.NetworkId("tron"), + suffix = CryptoCurrency.ID.Suffix.RawID("tron"), ), network = tronNetwork, name = "Tron", @@ -257,7 +257,7 @@ internal class TronPaymentUriParserTest { private val usdtToken = CryptoCurrency.Token( id = CryptoCurrency.ID( prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, - body = CryptoCurrency.ID.Body.NetworkId("TRON"), + body = CryptoCurrency.ID.Body.NetworkId("tron"), suffix = CryptoCurrency.ID.Suffix.RawID("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"), ), network = tronNetwork, @@ -271,8 +271,7 @@ internal class TronPaymentUriParserTest { private fun buildNetwork(rawNetworkId: String, name: String, symbol: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = name, currencySymbol = symbol, derivationPath = Network.DerivationPath.None, diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt index 9f1b39e4d4..f258d37a0b 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrencyChecksRepository.kt @@ -2,7 +2,11 @@ package com.tangem.data.tokens.repository import com.tangem.blockchain.blockchains.ethereum.eip1559.isGaslessTxSupported import com.tangem.blockchain.blockchains.polkadot.ExistentialDepositProvider -import com.tangem.blockchain.common.* +import com.tangem.blockchain.common.FeeResourceAmountProvider +import com.tangem.blockchain.common.MinimumSendAmountProvider +import com.tangem.blockchain.common.ReserveAmountProvider +import com.tangem.blockchain.common.UtxoAmountLimitProvider +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.getTotalStakingBalance import com.tangem.data.tokens.converters.UtxoConverter import com.tangem.domain.models.currency.CryptoCurrency @@ -67,7 +71,7 @@ internal class DefaultCurrencyChecksRepository( } override fun isNetworkSupportedForGaslessTx(network: Network): Boolean { - val blockchain = Blockchain.fromId(network.rawId) + val blockchain = network.toBlockchain() return blockchain.isGaslessTxSupported } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index 849883a543..2664312cb5 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -1,7 +1,7 @@ package com.tangem.data.transaction -import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder @@ -45,7 +45,7 @@ class DefaultGaslessTransactionRepository( val supportedTokensData = gaslessTxServiceApi.getSupportedTokens().getOrThrow() if (supportedTokensData.isSuccess) { - val networkBlockchain = Blockchain.fromId(network.rawId) + val networkBlockchain = network.toBlockchain() val supportedTokens = supportedTokensData.result.tokens .filter { it.chainId == networkBlockchain.getChainId() @@ -100,7 +100,7 @@ class DefaultGaslessTransactionRepository( network: Network, eip7702Auth: Eip7702Authorization?, ): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) { - val blockchain = Blockchain.fromId(network.rawId) + val blockchain = network.toBlockchain() val transactionRequest = gaslessTransactionRequestBuilder.build( gaslessTransaction = gaslessTransactionData, signature = signature, @@ -124,7 +124,7 @@ class DefaultGaslessTransactionRepository( } override fun getChainIdForNetwork(network: Network): Int { - val networkBlockchain = Blockchain.fromId(network.rawId) + val networkBlockchain = network.toBlockchain() return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index be0a8b25f4..2d6b86ba9a 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -2,6 +2,7 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -30,7 +31,7 @@ class MockedGaslessTransactionRepository( } override fun getChainIdForNetwork(network: Network): Int { - val networkBlockchain = Blockchain.fromId(network.rawId) + val networkBlockchain = network.toBlockchain() return networkBlockchain.getChainId() ?: error("ChainId not found for blockchain ${networkBlockchain.name}") } diff --git a/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt b/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt index 0211c42b33..b7f49cec2b 100644 --- a/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt +++ b/data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultAllowanceRepositoryTest.kt @@ -171,7 +171,11 @@ class DefaultAllowanceRepositoryTest { @Test fun `returns NotEnough when partial allowance for non-tether token`() = runTest { - val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "usd-coin") + val token = buildToken( + rawNetworkId = "ethereum", + rawCurrencyId = "usd-coin", + contractAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + ) coEvery { (approverWalletManager as Approver).getAllowance(spenderAddress, any()) @@ -187,7 +191,7 @@ class DefaultAllowanceRepositoryTest { @Test fun `returns NotEnough when partial allowance for tether on non-ethereum network`() = runTest { - val token = buildToken(rawNetworkId = "polygon", rawCurrencyId = "tether") + val token = buildToken(rawNetworkId = "polygon-pos", rawCurrencyId = "tether") coEvery { (approverWalletManager as Approver).getAllowance(spenderAddress, any()) @@ -200,7 +204,7 @@ class DefaultAllowanceRepositoryTest { @Test fun `returns ResetNeeded when partial allowance for tether on ethereum`() = runTest { - val token = buildToken(rawNetworkId = "ETH", rawCurrencyId = "tether") + val token = buildToken(rawNetworkId = "ethereum", rawCurrencyId = "tether") coEvery { (approverWalletManager as Approver).getAllowance(spenderAddress, any()) @@ -216,7 +220,7 @@ class DefaultAllowanceRepositoryTest { @Test fun `returns ResetNeeded when partial allowance for tether on ethereum testnet`() = runTest { - val token = buildToken(rawNetworkId = "ETH/test", rawCurrencyId = "tether") + val token = buildToken(rawNetworkId = "ethereum/test", rawCurrencyId = "tether") coEvery { (approverWalletManager as Approver).getAllowance(spenderAddress, any()) @@ -248,8 +252,7 @@ class DefaultAllowanceRepositoryTest { private fun buildNetwork(rawNetworkId: String): Network { val derivationPath = Network.DerivationPath.None return Network( - id = Network.ID(Network.RawID(rawNetworkId), derivationPath), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), name = rawNetworkId.replaceFirstChar { it.uppercase() }, currencySymbol = "ETH", derivationPath = derivationPath, @@ -263,7 +266,7 @@ class DefaultAllowanceRepositoryTest { } private fun buildToken( - rawNetworkId: String = "ETH", + rawNetworkId: String = "ethereum", rawCurrencyId: String = "tether", contractAddress: String = "0xdAC17F958D2ee523a2206206994597C13D831ec7", ): CryptoCurrency.Token { diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt index 8ef4d7a69e..b295d0356d 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcNetworksConverter.kt @@ -4,6 +4,7 @@ import com.reown.walletkit.client.Wallet import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.address.AddressType import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.data.common.currency.isCustomCoin import com.tangem.data.walletconnect.model.CAIP10 import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer @@ -84,7 +85,7 @@ internal class WcNetworksConverter @Inject constructor( val blockchain = namespaceConverters .firstNotNullOfOrNull { it.toBlockchain(rawChainId) } ?: return listOf() - val allCoinNetwork = portfolioNetworks.filter { it.rawId == blockchain.id } + val allCoinNetwork = portfolioNetworks.filter { it.rawId == blockchain.toNetworkId() } return allCoinNetwork } @@ -103,7 +104,7 @@ internal class WcNetworksConverter @Inject constructor( ?: return@mapNotNullTo null portfolioNetworks // find all derivation - .filter { it.rawId == blockchain.id } + .filter { it.rawId == blockchain.toNetworkId() } // find equal address .firstOrNull { network -> val walletAddress = getAddressForWC(wallet.walletId, network) diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt index cf8174d299..6d5251e8df 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt @@ -29,13 +29,14 @@ import com.tangem.blockchain.yieldsupply.providers.YieldSupplyStatus as SDKYield @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DefaultYieldSupplyTransactionRepositoryTest { - private val networkId = Network.ID(value = "ETH/test", derivationPath = Network.DerivationPath.None) + private val networkId = Network.ID(value = "ethereum/test", derivationPath = Network.DerivationPath.None) private val mockedContractAddress = "0x000000000000000000000000000000000000" private val yieldContractAddress = "0x1234" private val userWalletId = mockk() private val cryptoCurrency = mockk(relaxed = true) { every { network.id } returns networkId + every { network.backendId } returns networkId.rawId.value every { contractAddress } returns mockedContractAddress } private val cryptoCurrencyStatus = mockk(relaxed = true) { diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt index 24da2b65e0..9c33c3153e 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/AccountCryptoCurrencyStatusFinder.kt @@ -1,6 +1,6 @@ package com.tangem.domain.account.status.utils -import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.model.AccountCryptoCurrency @@ -180,7 +180,7 @@ internal object AccountCryptoCurrencyStatusFinder { contractAddress: String?, ): AccountCryptoCurrency? { return accountList.getExpectedAccounts( - rawNetworkId = networkId.rawId.value, + rawNetworkId = networkId.rawId, derivationPath = derivationPath, ) .asSequence() @@ -220,7 +220,7 @@ internal object AccountCryptoCurrencyStatusFinder { internal fun AccountStatusList.getExpectedAccountStatuses(networkId: Network.ID): List { val possibleAccountIndex = getAccountIndexOrNull( - rawNetworkId = networkId.rawId.value, + rawNetworkId = networkId.rawId, derivationPath = networkId.derivationPath, ) @@ -239,7 +239,7 @@ internal object AccountCryptoCurrencyStatusFinder { } internal fun AccountStatusList.getExpectedAccountStatuses(networks: List): List { - val possibleAccountIndexes = networks.mapNotNull { getAccountIndexOrNull(it.rawId, it.derivationPath) } + val possibleAccountIndexes = networks.mapNotNull { getAccountIndexOrNull(it.id.rawId, it.derivationPath) } if (possibleAccountIndexes.isEmpty()) return accountStatuses @@ -256,16 +256,14 @@ internal object AccountCryptoCurrencyStatusFinder { // region AccountList helpers internal fun AccountList.getExpectedAccounts(network: Network?): List { - return getExpectedAccounts(rawNetworkId = network?.rawId, derivationPath = network?.derivationPath) + return getExpectedAccounts(rawNetworkId = network?.id?.rawId, derivationPath = network?.derivationPath) } private fun AccountList.getExpectedAccounts( - rawNetworkId: String?, + rawNetworkId: Network.RawID?, derivationPath: Network.DerivationPath?, ): List { - val possibleAccountIndex = getAccountIndexOrNull(rawNetworkId, derivationPath) - - return when (possibleAccountIndex) { + return when (val possibleAccountIndex = getAccountIndexOrNull(rawNetworkId, derivationPath)) { null -> accounts DerivationIndex.Main.value -> listOf(mainAccount) // currency only in the account with specific derivation index or in the main account @@ -283,10 +281,10 @@ internal object AccountCryptoCurrencyStatusFinder { // region Common helpers - private fun getAccountIndexOrNull(rawNetworkId: String?, derivationPath: Network.DerivationPath?): Int? { + private fun getAccountIndexOrNull(rawNetworkId: Network.RawID?, derivationPath: Network.DerivationPath?): Int? { if (rawNetworkId == null || derivationPath == null) return null - val blockchain = Blockchain.fromId(id = rawNetworkId) + val blockchain = rawNetworkId.toBlockchain() val recognizer = AccountNodeRecognizer(blockchain) return recognizer.recognize(derivationPath)?.toInt() diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt index b5a972d24c..53be78f1ff 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt @@ -10,7 +10,6 @@ import kotlinx.serialization.Serializable * (e.g., ERC20, BEP20). * * @property id the unique identifier of the network - * @property backendId the name of this network in the Tangem backend * @property name the human-readable name of the network, such as "Ethereum" or "Bitcoin" * @property currencySymbol the symbol of the currency associated with the network * @property derivationPath the path used to derive keys for this network @@ -25,7 +24,6 @@ import kotlinx.serialization.Serializable @Serializable data class Network( val id: ID, - val backendId: String, val name: String, val currencySymbol: String, val derivationPath: DerivationPath, @@ -37,6 +35,11 @@ data class Network( val nameResolvingType: NameResolvingType, ) { + /** Backend ID */ + @Deprecated("Will be removed later") + val backendId: String + get() = id.rawId.value + /** Raw ID */ val rawId: String get() = id.rawId.value diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt index db33de7982..0705a3a520 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/model/StakingIntegrationID.kt @@ -147,7 +147,7 @@ sealed interface StakingIntegrationID { * @return a [StakingIntegrationID] if supported, or `null` if not supported. */ fun create(currencyId: CryptoCurrency.ID): StakingIntegrationID? { - val blockchain = Blockchain.fromId(id = currencyId.rawNetworkId) + val blockchain = currencyId.toBlockchain() return if (currencyId.contractAddress.isNullOrBlank()) { // Order is not important — either P2PEthPool or Stakekit.Coin can be in any order diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt index 3211cdff64..1d754890f5 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIdFactoryTest.kt @@ -5,6 +5,7 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.staking.StakingID @@ -13,11 +14,7 @@ import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.toggles.StakingFeatureToggles import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.test.core.ProvideTestModels -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk +import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -188,7 +185,7 @@ internal class StakingIdFactoryTest { ), CreateModel( currencyId = CryptoCurrency.ID.fromValue( - value = "token⟨ETH⟩polygon-ecosystem-token⚓0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", + value = "token⟨ethereum⟩polygon-ecosystem-token⚓0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0", ), expected = createStakingId(integrationId = StakingIntegrationID.StakeKit.EthereumToken.Polygon), ), @@ -202,6 +199,6 @@ internal class StakingIdFactoryTest { data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: Either) private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID { - return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}⟩") + return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.toNetworkId()}⟩") } } \ No newline at end of file diff --git a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt index c62e943341..c35f5af029 100644 --- a/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt +++ b/domain/staking/src/test/kotlin/com/tangem/domain/staking/StakingIntegrationIDTest.kt @@ -2,6 +2,7 @@ package com.tangem.domain.staking import com.google.common.truth.Truth import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.staking.model.StakingApproval import com.tangem.domain.staking.model.StakingIntegrationID @@ -144,11 +145,11 @@ class StakingIntegrationIDTest { expected = StakingIntegrationID.P2PEthPool, ), CreateModel( - currencyId = CryptoCurrency.ID.fromValue(value = "token⟨ETH⟩polygon-ecosystem-token⚓1234567890"), + currencyId = CryptoCurrency.ID.fromValue(value = "token⟨ethereum⟩polygon-ecosystem-token⚓1234567890"), expected = StakingIntegrationID.StakeKit.EthereumToken.Polygon, ), CreateModel( - currencyId = CryptoCurrency.ID.fromValue(value = "token⟨SOLANA⟩solana⚓1234567890"), + currencyId = CryptoCurrency.ID.fromValue(value = "token⟨solana⟩solana⚓1234567890"), expected = null, ), ) @@ -157,6 +158,6 @@ class StakingIntegrationIDTest { data class CreateModel(val currencyId: CryptoCurrency.ID, val expected: StakingIntegrationID?) private fun createCurrencyId(blockchain: Blockchain): CryptoCurrency.ID { - return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.id}⟩") + return CryptoCurrency.ID.fromValue(value = "coin⟨${blockchain.toNetworkId()}⟩") } } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt index d856cd5af9..6a98966d0d 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/mock/MockNetworks.kt @@ -18,7 +18,6 @@ internal object MockNetworks { name = "Network One", isTestnet = false, standardType = Network.StandardType.ERC20, - backendId = "network1", currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, hasFiatFeeRate = true, @@ -32,7 +31,6 @@ internal object MockNetworks { name = "Network Two", isTestnet = false, standardType = Network.StandardType.ERC20, - backendId = "network1", currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, hasFiatFeeRate = true, @@ -46,7 +44,6 @@ internal object MockNetworks { name = "Network Three", isTestnet = false, standardType = Network.StandardType.ERC20, - backendId = "network1", currencySymbol = "ETH", derivationPath = Network.DerivationPath.None, hasFiatFeeRate = true, diff --git a/domain/yield-supply/build.gradle.kts b/domain/yield-supply/build.gradle.kts index ea83aa3225..86c16fe968 100644 --- a/domain/yield-supply/build.gradle.kts +++ b/domain/yield-supply/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { /** Core */ implementation(projects.core.ui) implementation(projects.core.utils) + implementation(projects.libs.blockchainSdk) /** Domain */ implementation(projects.domain.account.status) diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt index 35cb09af2c..c5e83841ac 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCase.kt @@ -5,6 +5,7 @@ import arrow.core.Either.Companion.catch import arrow.core.getOrElse import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.domain.account.status.utils.CryptoCurrencyOperations.getCoin import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -67,8 +68,7 @@ class YieldSupplyGetCurrentFeeUseCase( val tokenValue = rateRatio.multiply(nativeGas.amount.value) - val isEthereum = cryptoCurrencyStatus.currency - .network.id.rawId.value == Blockchain.Ethereum.id + val isEthereum = cryptoCurrencyStatus.currency.network.rawId == Blockchain.Ethereum.toNetworkId() val isHighFee = if (isEthereum) { val maxFeePerGas = (feeWithoutGas as? Fee.Ethereum.EIP1559)?.maxFeePerGas ?: 0.toBigInteger() diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt index c26f6ba501..03680be520 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/YieldSupplyMinAmountUseCaseTest.kt @@ -165,8 +165,7 @@ class YieldSupplyMinAmountUseCaseTest { private fun createNetwork(): Network { val derivationPath = Network.DerivationPath.None return Network( - id = Network.ID(Network.RawID("polygon"), derivationPath), - backendId = "polygon", + id = Network.ID(value = "polygon", derivationPath = derivationPath), name = "Polygon", currencySymbol = "MATIC", derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt index d3f2581274..e8f69ad3c5 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyEnterStatusUseCaseTest.kt @@ -7,8 +7,8 @@ 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.UserWalletId -import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.yield.supply.YieldSupplyRepository import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import io.mockk.coEvery import io.mockk.coVerify @@ -324,7 +324,6 @@ class YieldSupplyEnterStatusUseCaseTest { val derivationPath = Network.DerivationPath.None val network = Network( id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt index f36d85dfaa..39ab76e2a6 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetCurrentFeeUseCaseTest.kt @@ -2,7 +2,7 @@ package com.tangem.domain.yield.supply.usecase import arrow.core.Either import com.google.common.truth.Truth.assertThat -import com.tangem.blockchain.common.Blockchain + import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.supplier.SingleAccountListSupplier @@ -48,7 +48,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN valid inputs on non-ethereum WHEN invoke THEN returns fee value and not high`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val tokenDecimals = 8 val nativeDecimals = 18 val token = createToken(rawNetworkId = rawNetworkId, decimals = tokenDecimals) @@ -100,7 +100,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN ethereum with high gas WHEN invoke THEN returns high fee flag`() = runTest { - val rawNetworkId = Blockchain.Ethereum.id + val rawNetworkId = "ethereum" val tokenDecimals = 8 val nativeDecimals = 18 val token = createToken(rawNetworkId = rawNetworkId, decimals = tokenDecimals) @@ -152,7 +152,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN token fiat rate missing WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = null) @@ -175,7 +175,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN native quotes unavailable WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = BigDecimal("2.00")) val nativeCoin = createCoin(rawNetworkId = rawNetworkId, decimals = 18) @@ -204,7 +204,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN empty quotes list WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = BigDecimal("2.00")) val nativeCoin = createCoin(rawNetworkId = rawNetworkId, decimals = 18) @@ -233,7 +233,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN native fiat rate non-positive WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = BigDecimal("2.00")) val nativeCoin = createCoin(rawNetworkId = rawNetworkId, decimals = 18) @@ -274,7 +274,7 @@ class YieldSupplyGetCurrentFeeUseCaseTest { @Test fun `GIVEN token fiat rate non-positive WHEN invoke THEN returns error`() = runTest { - val rawNetworkId = Blockchain.BSC.id + val rawNetworkId = "binance-smart-chain" val token = createToken(rawNetworkId = rawNetworkId, decimals = 8) val cryptoStatus = createStatus(token = token, fiatRate = BigDecimal.ZERO) // non-positive @@ -299,7 +299,6 @@ class YieldSupplyGetCurrentFeeUseCaseTest { val derivationPath = Network.DerivationPath.None val network = Network( id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, @@ -331,7 +330,6 @@ class YieldSupplyGetCurrentFeeUseCaseTest { val derivationPath = Network.DerivationPath.None val network = Network( id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetDustMinAmountUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetDustMinAmountUseCaseTest.kt index 67c35ae602..d8defa5bea 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetDustMinAmountUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetDustMinAmountUseCaseTest.kt @@ -61,8 +61,7 @@ class YieldSupplyGetDustMinAmountUseCaseTest { private fun createNetwork(): Network { val derivationPath = Network.DerivationPath.None return Network( - id = Network.ID(Network.RawID("polygon"), derivationPath), - backendId = "polygon", + id = Network.ID(value = "polygon", derivationPath = derivationPath), name = "Polygon", currencySymbol = "MATIC", derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt index 6d40c26c08..01534f3b92 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetRewardsBalanceUseCaseTest.kt @@ -238,8 +238,7 @@ class YieldSupplyGetRewardsBalanceUseCaseTest { @Test fun `GIVEN polygon USDT0 Loaded status WHEN invoke THEN emit formatted balances`() = runTest { val network = Network( - id = Network.ID(Network.RawID("POLYGON"), Network.DerivationPath.Card("m/44'/60'/0'/0/0")), - backendId = "polygon-pos", + id = Network.ID(value = "polygon-pos", derivationPath = Network.DerivationPath.Card("m/44'/60'/0'/0/0")), name = "Polygon", currencySymbol = "POL", derivationPath = Network.DerivationPath.Card("m/44'/60'/0'/0/0"), @@ -365,8 +364,7 @@ class YieldSupplyGetRewardsBalanceUseCaseTest { private fun createNetwork(): Network { val derivationPath = Network.DerivationPath.None return Network( - id = Network.ID(Network.RawID("polygon"), derivationPath), - backendId = "polygon", + id = Network.ID(value = "polygon", derivationPath = derivationPath), name = "Polygon", currencySymbol = "MATIC", derivationPath = derivationPath, diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt index e1c0349515..81fa8b8a84 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyPendingTrackerTest.kt @@ -279,7 +279,6 @@ class YieldSupplyPendingTrackerTest { val derivationPath = Network.DerivationPath.None val network = Network( id = Network.ID(value = networkId, derivationPath = derivationPath), - backendId = networkId, name = networkId, currencySymbol = networkId.take(3).uppercase(), derivationPath = derivationPath, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt index ac0bd61222..1a7e88c79a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewCustomTokenSelectorComponent.kt @@ -57,7 +57,6 @@ internal class PreviewCustomTokenSelectorComponent( CurrencyNetworkUM( network = Network( id = n.id, - backendId = n.id.rawId.value, name = "Network $index", currencySymbol = "N$index", derivationPath = Network.DerivationPath.Card(""), diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt index ed67875a8e..9f7567aee0 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewManageTokensComponent.kt @@ -154,7 +154,6 @@ internal class PreviewManageTokensComponent( CurrencyNetworkUM( network = Network( id = Network.ID(value = networkIndex.toString(), derivationPath = derivationPath), - backendId = networkIndex.toString(), name = "Network $networkIndex", currencySymbol = "N$networkIndex", derivationPath = derivationPath, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt index 7907f421fa..47fbb3f5e5 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/preview/PreviewOnboardingManageTokensComponent.kt @@ -95,7 +95,6 @@ internal class PreviewOnboardingManageTokensComponent( CurrencyNetworkUM( network = Network( id = Network.ID(value = networkIndex.toString(), derivationPath = derivationPath), - backendId = networkIndex.toString(), name = "Network $networkIndex", currencySymbol = "N$networkIndex", derivationPath = derivationPath, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt index 731f1757df..8931a13343 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenSelectorModel.kt @@ -3,7 +3,7 @@ package com.tangem.features.managetokens.model import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate -import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.ui.account.toUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -199,7 +199,7 @@ internal class CustomTokenSelectorModel @Inject constructor( private fun DerivationPathSelector.checkAccountDerivation(derivationPath: SelectedDerivationPath) = modelScope.launch { val account = derivationPath.id - ?.let { Blockchain.fromId(it.rawId.value) } + ?.toBlockchain() ?.let(::AccountNodeRecognizer) ?.let { recognizer -> val derivationPathValue = derivationPath.value.value diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt index e084555a7b..9e1f4c23df 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt @@ -133,7 +133,6 @@ private fun Preview() { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt index 5280350577..366e500c80 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -516,7 +516,6 @@ private val cryptoCurrencyStatus value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt index 76adedfcaf..7a57435e0e 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/component/token/ui/FeeTokenSelectorContent.kt @@ -171,7 +171,6 @@ private fun Preview() { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt index a74fac4f79..80548cd189 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/feeselector/ui/FeeSelectorBlockContent.kt @@ -258,7 +258,6 @@ private class FeeSelectorUMProvider : PreviewParameterProvider { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt index 92dc0a0d66..c338f3ca59 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt @@ -30,7 +30,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") private val analyticsCategoryName = "test_category" - val cryptoCurrencyStatus = CryptoCurrencyStatus( + private val cryptoCurrencyStatus = CryptoCurrencyStatus( currency = CryptoCurrency.Coin( id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), network = Network( @@ -38,7 +38,6 @@ class NFTSendConfirmationNotificationsTransformerV2Test { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index ee1d34d98e..aa166774b7 100644 --- a/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send-v2/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -32,7 +32,7 @@ class SendConfirmationNotificationsTransformerV2Test { private val appCurrency = AppCurrency(name = "US Dollar", code = "USD", symbol = "$") private val analyticsCategoryName = "test_category" - val cryptoCurrencyStatus = CryptoCurrencyStatus( + private val cryptoCurrencyStatus = CryptoCurrencyStatus( currency = CryptoCurrency.Coin( id = CryptoCurrency.ID.fromValue("coin⟨BITCOIN⟩bitcoin"), network = Network( @@ -40,7 +40,6 @@ class SendConfirmationNotificationsTransformerV2Test { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt index f15aca16d8..26beb5a846 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt @@ -33,7 +33,6 @@ internal data object SwapAmountContentPreview { value = "bitcoin", derivationPath = Network.DerivationPath.None, ), - backendId = "bitcoin", name = "Bitcoin", currencySymbol = "BTC", derivationPath = Network.DerivationPath.None, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt index 427406a32b..8e92b4c203 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt @@ -104,7 +104,6 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider Network.StandardType.ERC20 else -> Network.StandardType.Unspecified("UNSPEC") }, diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt index de88d92e2a..a2e0fbda2f 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -834,10 +834,9 @@ class DefaultPromoDeeplinkHandlerTest { address: String, derivationPath: Network.DerivationPath = Network.DerivationPath.None, ): NetworkStatus { - val networkId = Network.ID(Network.RawID(rawNetworkId), derivationPath) + val networkId = Network.ID(value = rawNetworkId, derivationPath = derivationPath) val network = Network( id = networkId, - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, @@ -866,10 +865,9 @@ class DefaultPromoDeeplinkHandlerTest { } private fun buildUnreachableNetworkStatus(rawNetworkId: String): NetworkStatus { - val networkId = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None) + val networkId = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None) val network = Network( id = networkId, - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = Network.DerivationPath.None, @@ -890,10 +888,9 @@ class DefaultPromoDeeplinkHandlerTest { rawNetworkId: String, derivationPath: Network.DerivationPath = Network.DerivationPath.None, ): CryptoCurrency.Coin { - val networkId = Network.ID(Network.RawID(rawNetworkId), derivationPath) + val networkId = Network.ID(value = rawNetworkId, derivationPath = derivationPath) val network = Network( id = networkId, - backendId = rawNetworkId, name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = derivationPath, diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt index 5b6afef517..9f687cf8d5 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/qr/QrContentClassifierTest.kt @@ -285,8 +285,7 @@ internal class QrContentClassifierTest { private fun buildNetwork(rawNetworkId: String): Network { return Network( - id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None), - backendId = rawNetworkId, + id = Network.ID(value = rawNetworkId, derivationPath = Network.DerivationPath.None), name = rawNetworkId, currencySymbol = rawNetworkId.take(3).uppercase(), derivationPath = Network.DerivationPath.None, diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt index 203d2fddbd..e78329bd4a 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/NetworkExt.kt @@ -1,6 +1,7 @@ package com.tangem.blockchainsdk.utils import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network /** Converts [Network] to [Blockchain] */ @@ -10,4 +11,8 @@ fun Network.toBlockchain(): Blockchain = id.toBlockchain() fun Network.ID.toBlockchain(): Blockchain = rawId.toBlockchain() /** Converts [Network.RawID] to [Blockchain] */ -fun Network.RawID.toBlockchain(): Blockchain = Blockchain.fromId(id = value) \ No newline at end of file +fun Network.RawID.toBlockchain(): Blockchain = value.toBlockchain() + +fun CryptoCurrency.ID.toBlockchain(): Blockchain = rawNetworkId.toBlockchain() + +private fun String.toBlockchain(): Blockchain = Blockchain.fromNetworkId(this) ?: Blockchain.Unknown \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index b3108e4f2e..cf3897b510 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -28,9 +28,9 @@ object BlockchainUtils { /** Decodes XRP Blockchain address */ fun decodeRippleXAddress(xAddress: String, blockchainId: String): XrpTaggedAddress? { - return if (blockchainId == Blockchain.XRP.id && xAddress.firstOrNull() == XRP_X_ADDRESS) { + return if (blockchainId.toBlockchain() == Blockchain.XRP && xAddress.firstOrNull() == XRP_X_ADDRESS) { val decodedAddress = XrpAddressService.decodeXAddress(xAddress) - return decodedAddress?.let(XrpTaggedAddressConverter()::convert) + decodedAddress?.let(XrpTaggedAddressConverter()::convert) } else { null } @@ -38,46 +38,46 @@ object BlockchainUtils { /** If current [networkId] is Bitcoin */ fun isBitcoin(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Bitcoin || blockchain == Blockchain.BitcoinTestnet } /** If current [networkId] is use custom fee */ fun isUseBitcoinFeeConverter(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return isBitcoin(blockchainId) || blockchain == Blockchain.Fact0rn } /** If current [blockchainId] is Tezos */ fun isTezos(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Tezos } fun isCardano(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Cardano } /** If current [blockchainId] is BeaconChain */ fun isBeaconChain(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Binance || blockchain == Blockchain.BinanceTestnet } /** If current [blockchainId] is Polygon */ fun isPolygonChain(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Polygon || blockchain == Blockchain.PolygonTestnet } fun isTron(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet } fun isTon(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet } @@ -89,7 +89,7 @@ object BlockchainUtils { coinId: String? = null, contractAddress: String? = null, ): Boolean { - val blockchain = Blockchain.fromNetworkId(blockchainId) ?: return false + val blockchain = blockchainId.toBlockchain() ?: return false if (blockchain in excludedBlockchains) return false if (hasOnlyHotWallets && blockchain in hotExcludedBlockchains) return false @@ -103,37 +103,37 @@ object BlockchainUtils { } fun isArbitrum(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Arbitrum } fun isSolana(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Solana } fun isPolkadot(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Polkadot || blockchain == Blockchain.PolkadotTestnet } fun isCosmos(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Cosmos || blockchain == Blockchain.CosmosTestnet } fun isBSC(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.BSC || blockchain == Blockchain.BSCTestnet } fun isEthereum(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet } fun isClore(blockchainId: String): Boolean { - return Blockchain.fromId(blockchainId) == Blockchain.Clore + return blockchainId.toBlockchain() == Blockchain.Clore } data class BlockchainInfo( @@ -163,7 +163,7 @@ object BlockchainUtils { * Blockchains not affecting total balance counting on errors */ fun isIncludeToBalanceOnError(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return when (blockchain) { Blockchain.Binance, Blockchain.BinanceTestnet -> true else -> false @@ -171,7 +171,7 @@ object BlockchainUtils { } fun isIncludeStakingTotalBalance(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return blockchain != Blockchain.Cardano } @@ -184,9 +184,9 @@ object BlockchainUtils { /** Checks if the blockchain uses case-insensitive contract addresses */ fun isCaseInsensitiveContractAddress(blockchainId: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() - return blockchain.isEvm() + return blockchain?.isEvm() == true } private fun getNetworkStandardName(blockchain: Blockchain): String { @@ -223,8 +223,10 @@ object BlockchainUtils { * Checks if the given coin is Tether on Ethereum network, which may require special handling in some cases. */ fun isTetherInEthereum(blockchainId: String, contractAddress: String): Boolean { - val blockchain = Blockchain.fromId(blockchainId) + val blockchain = blockchainId.toBlockchain() return (blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet) && contractAddress.equals(TETHER_CONTRACT_ADDRESS, ignoreCase = true) } + + private fun String.toBlockchain(): Blockchain? = Blockchain.fromNetworkId(this) } \ No newline at end of file From 799a38655e8df72e482ae08faae5ab373fd504a7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Apr 2026 11:10:57 +0300 Subject: [PATCH 014/206] Updated on 2026-08-14 --- .../src/main/res/values-pt-rBR/strings.xml | 23 ++ core/res/src/main/res/values/strings.xml | 19 + .../DefaultConsolidationRepository.kt | 22 +- .../model/DynamicAddressesDelegate.kt | 300 +++++++++++++-- .../tokendetails/model/TokenDetailsModel.kt | 24 +- .../DynamicAddressesBottomSheet.kt | 18 +- .../DynamicAddressesBottomSheetConfig.kt | 28 +- .../DynamicAddressesBottomSheetContent.kt | 364 +++++++++++++++++- gradle/tangem_dependencies.toml | 2 +- 9 files changed, 733 insertions(+), 67 deletions(-) diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index e931a23bdc..e011607a12 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -80,6 +80,10 @@ Adicionar tokens Escolha o token que deseja receber. Escolha o token que deseja trocar. + Adicione ao seu portfólio + Adicionar tokens + Classificação e agrupamento + Organizar tokens Escolha a rede Adicionar token personalizado Gerenciar tokens @@ -584,6 +588,8 @@ Escolha qual token usar para pagar a taxa de rede. %s Escolha o token Mercado e Notícias + Mostrar tudo + Mostrar menos Em alta agora As informações a seguir são opcionais. Você pode apagá-las se não quiser compartilhá-las. Diga-nos quais funções estão faltando e tentaremos ajudá-lo. @@ -719,6 +725,7 @@ Limite de mana A rede Koinos exige Mana para o pagamento das taxas de rede. Você tem %1$s/%2$s Mana Nível de mana + Adicionar e gerenciar Para começar a rastrear seus criptoativos e transações, adicione tokens. Gerenciar tokens Leia o código QR para enviar fundos ou conectar-se a um aplicativo @@ -1534,6 +1541,7 @@ Negociação em grande escala Teremos todo o prazer em receber seu feedback. O Tangem Pay agora está em versão beta. + Não foi possível renomear o cartão. Cartão bloqueado Pagamento com cartão Depósito @@ -1567,7 +1575,9 @@ Seu cartão foi desbloqueado. Retirada Não é possível usar em dispositivos com root. + Saldo disponível Ocultar KYC da tela principal + Cartão Tangem Pay 1 Adicionar fundos Opções de recarga Adicionar ao Google Wallet @@ -1600,10 +1610,13 @@ Compartilhe seu endereço ou mostre o código QR. Problemas técnicos detectados. Tente novamente mais tarde ou entre em contato com o suporte. Receber indisponível agora + Somente letras e números são permitidos. + Caracteres inválidos Revelar Mostrar detalhes Troque qualquer ativo da sua carteira por um cartão. Detalhes do cartão + Por favor, tente novamente mais tarde. Descongelar cartão Volte ao aplicativo se você se esquecer. Seu código PIN @@ -1611,12 +1624,16 @@ Retirada indisponível agora Você não pode iniciar uma troca ou um novo saque até que o atual seja concluído. Retirada em andamento + Configurações do cartão Alterar código PIN Volte ao aplicativo se você se esquecer. + Cartão digital Entendo que perderei completamente o acesso ao meu cartão Tangem Pay e a todos os fundos nele contidos, sem possibilidade de recuperação. Falha na emissão do cartão Ocorreu um erro técnico. Tente novamente clicando no botão abaixo. Ocorreu um erro técnico. Por favor, entre em contato com o suporte. + O recurso estará disponível em breve. + Você poderá emitir cartões adicionais para sua conta de pagamento. Obtenha seu cartão virtual Visa Tangem grátis. Obtenha o Tangem Pay Acesse o Suporte @@ -1648,6 +1665,7 @@ Uma conta de pagamento separada será criada sem divulgar seus endereços e bens. Privacidade incomparável Obtenha seu cartão Tangem Pay gratuito em minutos. + Suporte de pagamento Conta de pagamento A conta de pagamento não está sincronizada. PIN inválido: evite sequências ou repetições. @@ -1660,6 +1678,7 @@ Use USDC para pagamentos do dia a dia. O serviço Tangem Pay está temporariamente inacessível. Tangem Pay + USDC na rede Polygon Clique no botão abaixo para restaurar o acesso. O saldo do seu Polygon em USDC na blockchain é diferente do saldo do seu cartão e é atualizado em até 2 dias úteis após uma compra. Os fundos de compras reembolsadas não serão devolvidos ao seu saldo na blockchain nem estarão disponíveis para saque, mas permanecerão no saldo do seu cartão para compras futuras. Observe @@ -1690,6 +1709,10 @@ Serviço de staking %1$s token em %%imagem%% %2$s rede Token em %%imagem%% %1$s rede + %s rede + %1$s em %2$s rede + %1$s em %%imagem%% %2$s + %1$s em %2$s %%imagem%% O %1$s (%2$sO token ) é a principal moeda da plataforma. %3$s rede e não pode ser ocultada enquanto você tiver outros tokens dessa rede na lista Não foi possível ocultar %s N/A diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index dd706dc203..1ec9514666 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -80,6 +80,10 @@ Add tokens Choose the token you want to receive Choose the token you want to swap + Add to your portfolio + Add tokens + Sorting and grouping + Organize tokens Choose network Add custom token Manage tokens @@ -477,6 +481,11 @@ Send funds using only Dynamic addresses Dynamic Addresses is enabled. Custom \"change\" and \"index\" are unavailable. + You are turning off Dynamic Addresses. Funds will now be received on your fixed address. + A network fee applies to consolidate funds into the fixed address. + Disable Dynamic Addresses + Disable dynamic addresses + Dynamic addresses disabled Dynamic addresses enabled Use a new address for each transaction to reduce traceability and improve on-chain privacy. Enhanced Privacy @@ -584,6 +593,8 @@ Choose which token to use to pay\nthe network fee. %s Choose token Market & News + Show All + Show less Tangem AI Trending Now The following information is optional. You can erase it if you don\'t want to share it. @@ -720,6 +731,7 @@ Mana limit The Koinos network requires Mana for network fees. You have %1$s/%2$s Mana Mana level + Add & Manage To begin tracking your crypto assets and transactions, add tokens Manage tokens Scan QR code to send funds or connect to an app @@ -1569,7 +1581,9 @@ Your card is unfrozen. Withdrawal Unable to use on rooted device + Available balance Hide KYC from main screen + Tangem Pay Card 1 Add funds Top-up options Add to Google Wallet @@ -1619,10 +1633,13 @@ Card settings Change PIN-code Come back to the app if you forget it. + Digital card I understand that I will completely lose access to my Tangem Pay Card and all funds on it without the possibility of recovery Failed to issue card A technical error has occurred, please try again by clicking the button below. A technical error has occurred, please contact support. + Feature will be availible soon + You will be available to issue additional cards for your payment account Get your free Tangem Visa virtual card Get Tangem Pay Go to Support @@ -1654,6 +1671,7 @@ A separate payment account will be created without disclosing your addresses and assets Unrivaled privacy Get your free Tangem Pay Card in minutes + Pay Support Payment account Payment account is not synced Invalid PIN: avoid sequences or repeats @@ -1666,6 +1684,7 @@ Use USDC for everyday payments Tangem Pay is temporarily unreachable Tangem Pay + USDC on Polygon network Click the button below to restore access Your on-chain USDC Polygon balance differs from your card balance and updates within 2 business days after a purchase. Funds from refunded purchases won’t be returned your on-chain balance or be available for withdrawal, but will stay on your card balance for purchases. Please note diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt index 0860a7b9ce..c1b88d69d2 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultConsolidationRepository.kt @@ -1,10 +1,12 @@ package com.tangem.data.dynamicaddresses import arrow.core.Either +import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.DynamicAddressesManager import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.WalletManager import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.extensions.Result import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository import com.tangem.domain.models.network.Network @@ -33,13 +35,25 @@ internal class DefaultConsolidationRepository( when (val result = dynamicAddressesManager.createConsolidationTransaction(fee)) { is Result.Success -> result.data - is Result.Failure -> error("Failed to create consolidation tx: ${result.error}") + is Result.Failure -> throw result.error } } } - @Suppress("UnusedParameter") - private fun getNormalFee(walletManager: WalletManager): Fee { - TODO("Fee calculation for consolidation from multiple addresses is not yet implemented") + private suspend fun getNormalFee(walletManager: WalletManager): Fee { + val coinAmount = walletManager.wallet.amounts[AmountType.Coin] + ?: error("Coin amount not found") + val feeResult = walletManager.getFee( + amount = coinAmount, + destination = walletManager.wallet.address, + ) + + return when (feeResult) { + is Result.Success -> when (val txFee = feeResult.data) { + is TransactionFee.Single -> txFee.normal + is TransactionFee.Choosable -> txFee.normal + } + is Result.Failure -> throw feeResult.error + } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt index e9fe02cce1..a6a548aa4e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt @@ -1,42 +1,65 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import com.tangem.common.core.TangemSdkError +import com.tangem.core.decompose.di.GlobalUiMessageSender import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.res.R import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.common.ui.amountScreen.utils.getFiatString +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase +import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase +import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.utils.Provider +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @Suppress("LongParameterList") -internal class DynamicAddressesDelegate( +internal class DynamicAddressesDelegate @AssistedInject constructor( private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase, + private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase, + private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase, + private val getFeeUseCase: GetFeeUseCase, + private val sendTransactionUseCase: SendTransactionUseCase, private val isXpubDerivedUseCase: IsXpubDerivedUseCase, private val dynamicAddressesRepository: DynamicAddressesRepository, private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase, - private val uiMessageSender: UiMessageSender, - private val userWalletId: UserWalletId, - private val network: Network, - private val coroutineScope: CoroutineScope, + @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val dispatchers: CoroutineDispatcherProvider, - private val showBottomSheet: () -> Unit, - private val dismissBottomSheet: () -> Unit, - private val onDynamicAddressesEnabled: () -> Unit, + @Assisted private val userWallet: UserWallet, + @Assisted private val cryptoCurrencyStatusProvider: Provider, + @Assisted private val appCurrencyProvider: Provider, + @Assisted private val coroutineScope: CoroutineScope, + @Assisted("showBottomSheet") private val showBottomSheet: () -> Unit, + @Assisted("dismissBottomSheet") private val dismissBottomSheet: () -> Unit, + @Assisted("onDynamicAddressesStateChanged") private val onDynamicAddressesStateChanged: () -> Unit, ) { + private val userWalletId get() = userWallet.walletId + private val _bottomSheetConfig = MutableStateFlow( DynamicAddressesBottomSheetConfig.Enable( isCardScanRequired = false, @@ -45,27 +68,45 @@ internal class DynamicAddressesDelegate( ) val bottomSheetConfig: StateFlow = _bottomSheetConfig.asStateFlow() - fun onDynamicAddressesClick() { - coroutineScope.launch(dispatchers.main) { - val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) - if (hasConflicts) { - _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable( - onGotItClick = dismissBottomSheet, - ) - showBottomSheet() - return@launch - } + // region Entry point - val isCardScanRequired = !isXpubAlreadyDerived() - _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( - isCardScanRequired = isCardScanRequired, - onEnableClick = ::onEnableClick, - ) - showBottomSheet() + fun onDynamicAddressesClick() { + val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + coroutineScope.launch(dispatchers.main) { + val status = dynamicAddressesRepository.getStatus(userWalletId, network).first() + when (status) { + DynamicAddressesStatus.ENABLED, + DynamicAddressesStatus.ENABLED_REQUIRES_SETUP, + -> onDisableFlow(network) + DynamicAddressesStatus.DISABLED -> onEnableFlow(network) + } } } + // endregion + + // region Enable flow + + private suspend fun onEnableFlow(network: Network) { + val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network) + if (hasConflicts) { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( + onDismissClick = dismissBottomSheet, + ) + showBottomSheet() + return + } + + val isCardScanRequired = !isXpubAlreadyDerived(network) + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = isCardScanRequired, + onEnableClick = ::onEnableClick, + ) + showBottomSheet() + } + private fun onEnableClick() { + val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return coroutineScope.launch(dispatchers.main) { _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable( isCardScanRequired = false, @@ -80,7 +121,7 @@ internal class DynamicAddressesDelegate( } else { TangemLogger.e("Failed to get XPUB: ${error.message}") _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( - onGotItClick = dismissBottomSheet, + onDismissClick = dismissBottomSheet, ) } return@launch @@ -92,21 +133,21 @@ internal class DynamicAddressesDelegate( ifLeft = { error -> when (error) { is EnableDynamicAddressesError.ConflictingCustomTokens -> { - _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Unavailable( - onGotItClick = dismissBottomSheet, + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens( + onDismissClick = dismissBottomSheet, ) } is EnableDynamicAddressesError.ServiceError -> { TangemLogger.e("Failed to enable DA: ${error.cause.message}") _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( - onGotItClick = dismissBottomSheet, + onDismissClick = dismissBottomSheet, ) } } }, ifRight = { dismissBottomSheet() - onDynamicAddressesEnabled() + onDynamicAddressesStateChanged() uiMessageSender.send( SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_enabled_toast_title)), ) @@ -115,11 +156,208 @@ internal class DynamicAddressesDelegate( } } - private suspend fun isXpubAlreadyDerived(): Boolean { + // endregion + + // region Disable flow + + private fun onDisableFlow(network: Network) { + coroutineScope.launch(dispatchers.main) { + disableDynamicAddressesUseCase(userWalletId, network).fold( + ifLeft = { error -> + TangemLogger.e("Failed to check disable: ${error.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + showBottomSheet() + }, + ifRight = { isConsolidationRequired -> + if (!isConsolidationRequired) { + showSimpleDisableSheet() + } else { + showDisableSheetAndLoadFee() + } + }, + ) + } + } + + private fun showSimpleDisableSheet() { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation( + onDisableClick = ::onSimpleDisableClick, + onReadMoreClick = ::onReadMoreClick, + ) + showBottomSheet() + } + + private fun onSimpleDisableClick() { + val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + coroutineScope.launch(dispatchers.main) { + runSuspendCatching { dynamicAddressesRepository.disable(userWalletId, network) } + .onSuccess { + dismissBottomSheet() + onDynamicAddressesStateChanged() + uiMessageSender.send( + SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_disabled_popup_title)), + ) + } + .onFailure { e -> + TangemLogger.e("Failed to disable DA: ${e.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + } + } + } + + private fun showDisableSheetAndLoadFee() { + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading, + onDisableClick = ::onDisableClick, + onRefreshFee = ::loadDisableFee, + onReadMoreClick = ::onReadMoreClick, + ) + showBottomSheet() + loadDisableFee() + } + + private fun loadDisableFee() { + coroutineScope.launch(dispatchers.main) { + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading, + ) + + val status = cryptoCurrencyStatusProvider() + val currency = status?.currency + val balance = status?.value?.amount + val address = status?.value?.networkAddress?.defaultAddress?.value + + if (currency == null || balance == null || address == null) { + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error, + ) + return@launch + } + + getFeeUseCase( + amount = balance, + destination = address, + userWallet = userWallet, + cryptoCurrency = currency, + ).fold( + ifLeft = { + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error, + ) + }, + ifRight = { txFee -> + val fee = txFee.normal + val fiatFormatted = getFiatString( + value = fee.amount.value, + rate = status.value.fiatRate, + appCurrency = appCurrencyProvider(), + approximate = true, + ) + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Content( + feeSymbol = currency.symbol, + fiatFormatted = fiatFormatted, + ), + ) + }, + ) + } + } + + private fun disableWithConsolidationConfig(): DynamicAddressesBottomSheetConfig.DisableWithConsolidation { + return _bottomSheetConfig.value as? DynamicAddressesBottomSheetConfig.DisableWithConsolidation + ?: DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + onDisableClick = ::onDisableClick, + onRefreshFee = ::loadDisableFee, + onReadMoreClick = ::onReadMoreClick, + ) + } + + // TODO: Replace with actual DA documentation URL + private fun onReadMoreClick() { + // Stub: open documentation about Dynamic Addresses consolidation + } + + private fun onDisableClick() { + val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return + coroutineScope.launch(dispatchers.main) { + _bottomSheetConfig.value = disableWithConsolidationConfig().copy( + isSending = true, + ) + + val txData = createConsolidationTransactionUseCase(userWalletId, network).fold( + ifLeft = { error -> + TangemLogger.e("Failed to create consolidation tx: ${error.message}") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + return@launch + }, + ifRight = { it }, + ) + + sendTransactionUseCase( + txData = txData, + userWallet = userWallet, + network = network, + ).fold( + ifLeft = { error -> + if (error is SendTransactionError.UserCancelledError) { + dismissBottomSheet() + } else { + TangemLogger.e("Failed to send consolidation tx: $error") + _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( + onDismissClick = dismissBottomSheet, + ) + } + }, + ifRight = { + try { + dynamicAddressesRepository.disable(userWalletId, network) + } catch (e: Exception) { + TangemLogger.e("Failed to disable DA after consolidation: ${e.message}") + } + dismissBottomSheet() + onDynamicAddressesStateChanged() + uiMessageSender.send( + SnackbarMessage( + message = resourceReference(R.string.dynamic_addresses_disabled_popup_title), + ), + ) + }, + ) + } + } + + // endregion + + // region Common + + private suspend fun isXpubAlreadyDerived(network: Network): Boolean { return isXpubDerivedUseCase(userWalletId, network) } private fun isUserCancellation(error: Throwable): Boolean { return error is TangemSdkError.UserCancelled || error.cause is TangemSdkError.UserCancelled } + + // endregion + + @AssistedFactory + interface Factory { + @Suppress("LongParameterList") + fun create( + userWallet: UserWallet, + cryptoCurrencyStatusProvider: Provider, + appCurrencyProvider: Provider, + coroutineScope: CoroutineScope, + @Assisted("showBottomSheet") showBottomSheet: () -> Unit, + @Assisted("dismissBottomSheet") dismissBottomSheet: () -> Unit, + @Assisted("onDynamicAddressesStateChanged") onDynamicAddressesStateChanged: () -> Unit, + ): DynamicAddressesDelegate + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index f5def6e3a1..bb946cf2a8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -11,10 +11,7 @@ import com.tangem.blockchain.common.address.AddressType import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains -import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase -import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase -import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.receive.AddressModel @@ -170,11 +167,9 @@ internal class TokenDetailsModel @Inject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase, private val signCloreMessageUseCase: SignCloreMessageUseCase, - private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase, private val isXpubSupportedUseCase: IsXpubSupportedUseCase, - private val isXpubDerivedUseCase: IsXpubDerivedUseCase, - private val dynamicAddressesRepository: DynamicAddressesRepository, private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val dynamicAddressesDelegateFactory: DynamicAddressesDelegate.Factory, private val dialogFactory: TokenDetailsDialogFactory, private val userWalletsListRepository: UserWalletsListRepository, private val singleAccountListSupplier: SingleAccountListSupplier, @@ -246,21 +241,16 @@ internal class TokenDetailsModel @Inject constructor( // region Dynamic Addresses val dynamicAddressesDelegate by lazy(mode = LazyThreadSafetyMode.NONE) { - DynamicAddressesDelegate( - enableDynamicAddressesUseCase = enableDynamicAddressesUseCase, - isXpubDerivedUseCase = isXpubDerivedUseCase, - dynamicAddressesRepository = dynamicAddressesRepository, - getExtendedPublicKeyUseCase = getExtendedPublicKeyForCurrencyUseCase, - uiMessageSender = uiMessageSender, - userWalletId = userWalletId, - network = cryptoCurrency.network, + dynamicAddressesDelegateFactory.create( + userWallet = userWallet, + cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, + appCurrencyProvider = Provider { selectedAppCurrencyFlow.value }, coroutineScope = modelScope, - dispatchers = dispatchers, showBottomSheet = { bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.DynamicAddresses) }, dismissBottomSheet = bottomSheetNavigation::dismiss, - onDynamicAddressesEnabled = ::onDynamicAddressesEnabled, + onDynamicAddressesStateChanged = ::onDynamicAddressesStateChanged, ) } // endregion Dynamic Addresses @@ -707,7 +697,7 @@ internal class TokenDetailsModel @Inject constructor( override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick() - private fun onDynamicAddressesEnabled() { + private fun onDynamicAddressesStateChanged() { updateTopBarMenu() modelScope.launch(dispatchers.main) { cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt index a12557f768..556fd79f30 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheet.kt @@ -4,12 +4,14 @@ import androidx.compose.runtime.Composable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.R as CoreR @Composable internal fun DynamicAddressesBottomSheet(config: TangemBottomSheetConfig) { TangemModalBottomSheet( config = config, + containerColor = TangemTheme.colors.background.tertiary, title = { TangemModalBottomSheetTitle( endIconRes = CoreR.drawable.ic_close_24, @@ -19,10 +21,18 @@ internal fun DynamicAddressesBottomSheet(config: TangemBottomSheetConfig) { ) { content -> when (content) { is DynamicAddressesBottomSheetConfig.Enable -> DynamicAddressesEnableContent(content = content) - is DynamicAddressesBottomSheetConfig.Unavailable -> DynamicAddressesUnavailableContent(content = content) - is DynamicAddressesBottomSheetConfig.ServiceUnavailable -> DynamicAddressesServiceUnavailableContent( - content = content, - ) + is DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation -> { + DynamicAddressesDisableWithoutConsolidationContent(content = content) + } + is DynamicAddressesBottomSheetConfig.DisableWithConsolidation -> { + DynamicAddressesDisableWithConsolidationContent(content = content) + } + is DynamicAddressesBottomSheetConfig.ConflictingCustomTokens -> { + DynamicAddressesConflictingCustomTokensContent(content = content) + } + is DynamicAddressesBottomSheetConfig.ServiceUnavailable -> { + DynamicAddressesServiceUnavailableContent(content = content) + } } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt index a228fb29b2..7270a74d1e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetConfig.kt @@ -12,11 +12,33 @@ internal sealed class DynamicAddressesBottomSheetConfig : TangemBottomSheetConfi val onEnableClick: () -> Unit, ) : DynamicAddressesBottomSheetConfig() - data class Unavailable( - val onGotItClick: () -> Unit, + data class DisableWithoutConsolidation( + val onDisableClick: () -> Unit, + val onReadMoreClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + data class DisableWithConsolidation( + val feeState: DisableFeeState = DisableFeeState.Loading, + val isSending: Boolean = false, + val onDisableClick: () -> Unit, + val onRefreshFee: () -> Unit, + val onReadMoreClick: () -> Unit, + ) : DynamicAddressesBottomSheetConfig() + + sealed interface DisableFeeState { + data object Loading : DisableFeeState + data class Content( + val feeSymbol: String, + val fiatFormatted: String, + ) : DisableFeeState + data object Error : DisableFeeState + } + + data class ConflictingCustomTokens( + val onDismissClick: () -> Unit, ) : DynamicAddressesBottomSheetConfig() data class ServiceUnavailable( - val onGotItClick: () -> Unit, + val onDismissClick: () -> Unit, ) : DynamicAddressesBottomSheetConfig() } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt index fb1eaa7605..1b40922d1d 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/dynamicaddresses/DynamicAddressesBottomSheetContent.kt @@ -1,5 +1,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses +import android.content.res.Configuration +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,12 +16,28 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.audits.AuditLabel +import com.tangem.core.ui.components.audits.AuditLabelUM +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.res.R import com.tangem.core.ui.R as CoreR +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig.DisableFeeState @Composable internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetConfig.Enable) { @@ -72,13 +90,13 @@ internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetC Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) - PrimaryButton( + PrimaryButtonIconEnd( text = stringResourceSafe(id = R.string.dynamic_addresses_enter_main_button_title), + iconResId = if (content.isCardScanRequired) CoreR.drawable.ic_tangem_24 else null, onClick = content.onEnableClick, modifier = Modifier.fillMaxWidth(), showProgress = content.isLoading, enabled = !content.isLoading, - // TODO add card icon when isCardScanRequired ) Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) @@ -86,12 +104,205 @@ internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetC } @Composable -internal fun DynamicAddressesUnavailableContent(content: DynamicAddressesBottomSheetConfig.Unavailable) { +internal fun DynamicAddressesDisableWithoutConsolidationContent( + content: DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DisableHeader() + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButtonIconEnd( + text = stringResourceSafe(id = R.string.dynamic_addresses_disable_main_button_title), + iconResId = null, + onClick = content.onDisableClick, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +internal fun DynamicAddressesDisableWithConsolidationContent( + content: DynamicAddressesBottomSheetConfig.DisableWithConsolidation, +) { + val isConfirmEnabled = content.feeState is DisableFeeState.Content && !content.isSending + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DisableHeader() + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + + DisableFeeBlock( + feeState = content.feeState, + onReadMoreClick = content.onReadMoreClick, + ) + + if (content.feeState is DisableFeeState.Error) { + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Notification( + config = NotificationConfig( + title = resourceReference(R.string.send_fee_unreachable_error_title), + subtitle = resourceReference(R.string.send_fee_unreachable_error_text), + iconResId = CoreR.drawable.ic_alert_24, + iconTint = NotificationConfig.IconTint.Warning, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_button_refresh), + onClick = content.onRefreshFee, + ), + ), + containerColor = TangemTheme.colors.background.action, + ) + } + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24)) + + PrimaryButtonIconEnd( + text = stringResourceSafe(id = R.string.dynamic_addresses_disable_main_button_title), + iconResId = CoreR.drawable.ic_tangem_24, + onClick = content.onDisableClick, + modifier = Modifier.fillMaxWidth(), + showProgress = content.isSending, + enabled = isConfirmEnabled, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16)) + } +} + +@Composable +private fun DisableHeader() { + Icon( + painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable), + contentDescription = null, + modifier = Modifier.size(TangemTheme.dimens.size44), + tint = TangemTheme.colors.icon.attention, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses_disable_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + + Text( + text = stringResourceSafe(id = R.string.dynamic_addresses_disable_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun DisableFeeBlock(feeState: DisableFeeState, onReadMoreClick: () -> Unit) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersXMedium, + ) + .padding(TangemTheme.dimens.spacing12), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResourceSafe(id = R.string.common_network_fee_title), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + when (feeState) { + is DisableFeeState.Loading -> TextShimmer( + style = TangemTheme.typography.body2, + modifier = Modifier.size( + width = TangemTheme.dimens.size80, + height = TangemTheme.dimens.spacing16, + ), + ) + is DisableFeeState.Content -> Row( + verticalAlignment = Alignment.CenterVertically, + ) { + AuditLabel( + state = AuditLabelUM( + text = stringReference(feeState.feeSymbol), + type = AuditLabelUM.Type.General, + ), + ) + Text( + text = feeState.fiatFormatted, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.padding(start = TangemTheme.dimens.spacing4), + ) + } + is DisableFeeState.Error -> Text( + text = "\u2014", + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + } + } + + Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8)) + DisableFeeDescription(onReadMoreClick = onReadMoreClick) + } +} + +@Composable +private fun DisableFeeDescription(onReadMoreClick: () -> Unit) { + val readMoreText = stringResourceSafe(id = R.string.common_read_more) + val fullText = stringResourceSafe(id = R.string.dynamic_addresses_disable_fee_description) + + val annotatedString = buildAnnotatedString { + withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { + append(fullText) + append(" ") + } + withLink( + link = LinkAnnotation.Clickable( + tag = "read_more", + linkInteractionListener = { onReadMoreClick() }, + ), + ) { + withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) { + append(readMoreText) + } + } + } + + Text( + text = annotatedString, + style = TangemTheme.typography.caption2, + ) +} + +@Composable +internal fun DynamicAddressesConflictingCustomTokensContent( + content: DynamicAddressesBottomSheetConfig.ConflictingCustomTokens, +) { ErrorContent( titleRes = R.string.dynamic_addresses_error_has_custom_token_title, descriptionRes = R.string.dynamic_addresses_error_has_custom_token_description, buttonTextRes = R.string.common_got_it, - onButtonClick = content.onGotItClick, + onButtonClick = content.onDismissClick, ) } @@ -101,7 +312,7 @@ internal fun DynamicAddressesServiceUnavailableContent(content: DynamicAddresses titleRes = R.string.dynamic_addresses_error_service_unavailable_title, descriptionRes = R.string.dynamic_addresses_error_service_unavailable_description, buttonTextRes = R.string.common_got_it, - onButtonClick = content.onGotItClick, + onButtonClick = content.onDismissClick, ) } @@ -117,7 +328,7 @@ private fun ErrorContent(titleRes: Int, descriptionRes: Int, buttonTextRes: Int, painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable), contentDescription = null, modifier = Modifier.size(TangemTheme.dimens.size44), - tint = TangemTheme.colors.icon.warning, + tint = TangemTheme.colors.icon.attention, ) Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12)) @@ -176,4 +387,143 @@ private fun FeatureItem(iconRes: Int, title: String, description: String) { ) } } -} \ No newline at end of file +} + +// region Previews + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_Enable() { + TangemThemePreview { + DynamicAddressesEnableContent( + content = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = false, + onEnableClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_EnableWithCardScan() { + TangemThemePreview { + DynamicAddressesEnableContent( + content = DynamicAddressesBottomSheetConfig.Enable( + isCardScanRequired = true, + onEnableClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableWithoutConsolidation() { + TangemThemePreview { + DynamicAddressesDisableWithoutConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation( + onDisableClick = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableFeeLoading() { + TangemThemePreview { + DynamicAddressesDisableWithConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DisableFeeState.Loading, + onDisableClick = {}, + onRefreshFee = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableFeeLoaded() { + TangemThemePreview { + DynamicAddressesDisableWithConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DisableFeeState.Content( + feeSymbol = "BTC", + fiatFormatted = "~$0.12", + ), + onDisableClick = {}, + onRefreshFee = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableFeeError() { + TangemThemePreview { + DynamicAddressesDisableWithConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DisableFeeState.Error, + onDisableClick = {}, + onRefreshFee = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_DisableSending() { + TangemThemePreview { + DynamicAddressesDisableWithConsolidationContent( + content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation( + feeState = DisableFeeState.Content( + feeSymbol = "BTC", + fiatFormatted = "~$0.12", + ), + isSending = true, + onDisableClick = {}, + onRefreshFee = {}, + onReadMoreClick = {}, + ), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ConflictingCustomTokens() { + TangemThemePreview { + DynamicAddressesConflictingCustomTokensContent( + content = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(onDismissClick = {}), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_ServiceUnavailable() { + TangemThemePreview { + DynamicAddressesServiceUnavailableContent( + content = DynamicAddressesBottomSheetConfig.ServiceUnavailable(onDismissClick = {}), + ) + } +} + +// endregion \ No newline at end of file diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 165d042410..d9b87d6b95 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1482" +tangemBlockchainSdk = "develop-1486" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-602" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 4570bc334c149fceb93e9164396291981465f674 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Apr 2026 13:26:27 +0400 Subject: [PATCH 015/206] Updated on 2026-08-14 --- .../exchangeServices/DefaultRampManager.kt | 2 +- .../com/tangem/common/StakingBalanceExt.kt | 4 +- .../common/currency/CryptoCurrencyFactory.kt | 2 +- .../DefaultCardCryptoCurrencyFactory.kt | 4 +- .../currency/UserTokensResponseFactory.kt | 2 +- .../DefaultDynamicAddressesRepository.kt | 4 +- .../DefaultCustomTokensRepository.kt | 2 +- .../SimpleNetworkStatusConverterTest.kt | 4 +- .../tangem/data/nft/DefaultNFTRepository.kt | 2 +- .../data/onramp/DefaultOnrampRepository.kt | 8 +- .../DefaultQrScanningEventsRepositoryTest.kt | 6 +- .../data/staking/DefaultStakeKitRepository.kt | 2 +- .../data/swap/DefaultSwapRepositoryV2.kt | 18 ++-- .../data/swap/converter/TokenInfoConverter.kt | 2 +- .../repository/DefaultCurrenciesRepository.kt | 2 +- .../repository/DefaultTokenSyncRepository.kt | 10 +- .../transaction/DefaultAllowanceRepository.kt | 2 +- .../DefaultTransactionRepository.kt | 2 +- .../sign/BlockAidChainNameConverter.kt | 2 +- .../supply/DefaultYieldSupplyRepository.kt | 10 +- ...ultYieldSupplyTransactionRepositoryTest.kt | 2 +- .../usecase/ArchiveCryptoPortfolioUseCase.kt | 2 +- .../usecase/ManageCryptoCurrenciesUseCase.kt | 10 +- .../ArchiveCryptoPortfolioUseCaseTest.kt | 2 +- .../earn/usecase/GetEarnNetworksUseCase.kt | 2 +- .../currency/CryptoCurrencyExtensions.kt | 2 +- .../tangem/domain/models/network/Network.kt | 5 - .../tokens/wallet/WalletBalanceFetcher.kt | 2 +- .../usecase/GetEthSpecificFeeUseCase.kt | 4 +- .../portfolio/add/AvailableToAddData.kt | 4 +- .../details/MarketsTokenDetailsModel.kt | 2 +- .../model/CustomTokenFormModel.kt | 2 +- .../managetokens/model/ManageTokensModel.kt | 2 +- .../model/OnboardingManageTokensModel.kt | 2 +- .../list/CustomTokenFormUseCasesFacade.kt | 2 +- .../utils/list/ManageTokensUseCasesFacade.kt | 2 +- .../portfolio/model/OnrampAddTokenModel.kt | 2 +- .../model/AvailableSwapPairsModel.kt | 6 +- .../notifications/model/NotificationsModel.kt | 2 +- .../deeplink/DefaultStakingDeepLinkHandler.kt | 2 +- .../StakingBalanceEntryConverter.kt | 2 +- .../AddStakingNotificationsTransformer.kt | 4 +- .../SwapChooseContentStateTransformer.kt | 2 +- .../converters/LeastTokenInfoConverter.kt | 2 +- .../feature/swap/domain/SwapInteractorImpl.kt | 56 +++++------ .../impl/model/MarketBlockDelegate.kt | 2 +- .../tangem/feature/swap/model/SwapModel.kt | 6 +- .../tangem/feature/swap/ui/StateBuilder.kt | 20 ++-- .../DefaultTokenDetailsDeepLinkHandler.kt | 2 +- .../TokenDetailsNotificationConverter.kt | 2 +- .../utils/TokenListAnalyticsSender.kt | 2 +- .../YieldSupplyPromoBannerConverterTest.kt | 12 +-- .../model/YieldSupplyNotificationsModel.kt | 2 +- .../com/tangem/lib/crypto/BlockchainUtils.kt | 98 +++++++++---------- 54 files changed, 178 insertions(+), 183 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt index 285f2c46e9..198f3b63d8 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt @@ -188,7 +188,7 @@ internal class DefaultRampManager( private fun CryptoCurrency.findAssetPredicate(assetId: ExpressAsset.ID): Boolean { val currencyAssedId = ExpressAsset.ID( - networkId = this.network.backendId, + networkId = this.network.rawId, contractAddress = (this as? CryptoCurrency.Token)?.contractAddress, ) diff --git a/common/src/main/kotlin/com/tangem/common/StakingBalanceExt.kt b/common/src/main/kotlin/com/tangem/common/StakingBalanceExt.kt index 9994416d24..7cf1eebdc6 100644 --- a/common/src/main/kotlin/com/tangem/common/StakingBalanceExt.kt +++ b/common/src/main/kotlin/com/tangem/common/StakingBalanceExt.kt @@ -47,7 +47,7 @@ fun StakingBalance.Data.getTotalStakingBalance(blockchainId: String): BigDecimal * StakeKit-specific extension to get total balance including rewards. */ private fun StakingBalance.Data.StakeKit.getTotalWithRewardsStakingBalanceStakeKit(blockchainId: String): BigDecimal { - return if (BlockchainUtils.isIncludeStakingTotalBalance(blockchainId = blockchainId)) { + return if (BlockchainUtils.isIncludeStakingTotalBalance(networkId = blockchainId)) { balance.items.sumOf { it.amount } } else { getRewardStakingBalance() @@ -58,7 +58,7 @@ private fun StakingBalance.Data.StakeKit.getTotalWithRewardsStakingBalanceStakeK * StakeKit-specific extension to get total staked balance excluding rewards. */ private fun StakingBalance.Data.StakeKit.getTotalStakingBalanceStakeKit(blockchainId: String): BigDecimal { - return if (BlockchainUtils.isIncludeStakingTotalBalance(blockchainId = blockchainId)) { + return if (BlockchainUtils.isIncludeStakingTotalBalance(networkId = blockchainId)) { balance.items .filterNot { it.type == BalanceType.REWARDS } .sumOf { it.amount } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt index 4b7cffe26c..8773f976e7 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/CryptoCurrencyFactory.kt @@ -169,7 +169,7 @@ class CryptoCurrencyFactory( id = cryptoCurrency.id.rawCurrencyId?.value, ) val blockchain = - Blockchain.fromNetworkId(cryptoCurrency.network.backendId) ?: Blockchain.Unknown + Blockchain.fromNetworkId(cryptoCurrency.network.rawId) ?: Blockchain.Unknown val id = getTokenId(network, sdkToken) return CryptoCurrency.Token( id = id, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt index f56f43a761..c3a4e59b8d 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/DefaultCardCryptoCurrencyFactory.kt @@ -47,7 +47,7 @@ internal class DefaultCardCryptoCurrencyFactory( // check if the blockchain of single-currency wallet is the same as network val cardNetworkId = userWallet.scanResponse.cardTypesResolver.getBlockchain().toNetworkId() - val cardNetwork = networks.firstOrNull { it.backendId == cardNetworkId } + val cardNetwork = networks.firstOrNull { it.rawId == cardNetworkId } if (cardNetwork == null) return emptyMap() @@ -145,7 +145,7 @@ internal class DefaultCardCryptoCurrencyFactory( responseCryptoCurrenciesFactory.createCurrencies( tokens = accountDTO.tokens.orEmpty().filter { token -> networks.any { - it.backendId == token.networkId && it.derivationPath.value == token.derivationPath + it.rawId == token.networkId && it.derivationPath.value == token.derivationPath } }, userWallet = userWallet, diff --git a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt index 85bf2f102f..9bd1896c03 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/currency/UserTokensResponseFactory.kt @@ -40,7 +40,7 @@ class UserTokensResponseFactory @Inject constructor() { UserTokensResponse.Token( id = id.rawCurrencyId?.value, accountId = accountId?.value, - networkId = network.backendId, + networkId = network.rawId, derivationPath = network.derivationPath.value, name = name, symbol = symbol, diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index a9d2bcd8b7..c2c6203803 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -87,7 +87,7 @@ internal class DefaultDynamicAddressesRepository( .flatMap { it.tokens.orEmpty() } .any { token -> val tokenDerivationPath = token.derivationPath ?: return@any false - token.networkId == network.backendId && + token.networkId == network.rawId && tokenDerivationPath != baseDerivationPath && hasNonZeroChangeOrIndex(tokenDerivationPath, baseDerivationPath) } @@ -151,7 +151,7 @@ internal class DefaultDynamicAddressesRepository( } private fun UserTokensResponse.Token.matchesNetwork(network: Network): Boolean { - return networkId == network.backendId && + return networkId == network.rawId && derivationPath == network.derivationPath.value && contractAddress == null } diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt index dcf575b79f..db25b8597e 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt @@ -91,7 +91,7 @@ internal class DefaultCustomTokensRepository( val response = tangemTechApi.getCoins( contractAddress = contractAddress, - networkIds = network.backendId, + networkIds = network.rawId, active = true, ).getOrThrow() diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt index a7413a891e..0af092a1ca 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt @@ -75,7 +75,7 @@ internal class SimpleNetworkStatusConverterTest { ), expected = SimpleNetworkStatus( id = Network.ID( - value = network.backendId, + value = network.rawId, derivationPath = Network.DerivationPath.Card("card"), ), value = NetworkStatus.Verified( @@ -138,7 +138,7 @@ internal class SimpleNetworkStatusConverterTest { ), expected = SimpleNetworkStatus( id = Network.ID( - value = network.backendId, + value = network.rawId, derivationPath = Network.DerivationPath.Card("card"), ), value = NetworkStatus.NoAccount( diff --git a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt index 3baa26a3f4..d5e0ee4f95 100644 --- a/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt +++ b/data/nft/src/main/kotlin/com/tangem/data/nft/DefaultNFTRepository.kt @@ -568,7 +568,7 @@ internal class DefaultNFTRepository @Inject constructor( private fun Network.canHandleNFTs(userWalletId: UserWalletId): Boolean { val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - val blockchain = Blockchain.fromNetworkId(backendId) ?: return false + val blockchain = Blockchain.fromNetworkId(rawId) ?: return false return blockchain.canHandleNFTs() && userWallet.canHandleToken(blockchain, excludedBlockchains) diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index 7fe880aa2b..7ab90c6574 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -249,7 +249,7 @@ internal class DefaultOnrampRepository( to = listOf( OnrampDestinationDTO( contractAddress = cryptoCurrency.getContractAddress(), - network = cryptoCurrency.network.backendId, + network = cryptoCurrency.network.rawId, ), ), ), @@ -314,7 +314,7 @@ internal class DefaultOnrampRepository( to = listOf( OnrampDestinationDTO( contractAddress = cryptoCurrency.getContractAddress(), - network = cryptoCurrency.network.backendId, + network = cryptoCurrency.network.rawId, ), ), ), @@ -363,7 +363,7 @@ internal class DefaultOnrampRepository( fromCurrencyCode = currency.code, fromPrecision = currency.precision, toContractAddress = cryptoCurrency.getContractAddress(), - toNetwork = cryptoCurrency.network.backendId, + toNetwork = cryptoCurrency.network.rawId, paymentMethod = paymentMethod.id, countryCode = country.code, fromAmount = fromAmount, @@ -436,7 +436,7 @@ internal class DefaultOnrampRepository( fromCurrencyCode = currency.code, fromPrecision = currency.precision, toContractAddress = cryptoCurrency.getContractAddress(), - toNetwork = cryptoCurrency.network.backendId, + toNetwork = cryptoCurrency.network.rawId, paymentMethod = quote.paymentMethod.id, countryCode = country.code, fromAmount = fromAmountString, diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt index f904a7a32b..4321199575 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt @@ -78,7 +78,7 @@ internal class DefaultQrScanningEventsRepositoryTest { @Test fun testBip021() { every { network.id } returns Network.ID(value = "bitcoin", derivationPath = Network.DerivationPath.None) - every { network.backendId } returns "bitcoin" + every { network.rawId } returns "bitcoin" positiveCase( "$schema1:$address1", QrResult(address = address1), @@ -129,7 +129,7 @@ internal class DefaultQrScanningEventsRepositoryTest { @Test fun testErc681Coin() { every { network.id } returns Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) - every { network.backendId } returns "ethereum" + every { network.rawId } returns "ethereum" positiveCase( address2, QrResult(address = address2), @@ -185,7 +185,7 @@ internal class DefaultQrScanningEventsRepositoryTest { @Test fun testErc681Token() { every { network.id } returns Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) - every { network.backendId } returns "ethereum" + every { network.rawId } returns "ethereum" positiveCase( address2, QrResult(address = address2), diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt index 79dc265b4f..8f0fd77aec 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt @@ -411,7 +411,7 @@ internal class DefaultStakeKitRepository( } private fun getTronResource(network: Network): TronResource? { - val blockchain = Blockchain.fromNetworkId(network.backendId) + val blockchain = Blockchain.fromNetworkId(network.rawId) return if (blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet) { TronResource.ENERGY diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index a2c353ef57..eea56a309b 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -91,12 +91,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( val statusFrom = cryptoCurrencyStatusList .firstOrNull { currencyStatus -> currencyStatus.currency.getContractAddress() == pair.from.contractAddress && - currencyStatus.currency.network.backendId == pair.from.network + currencyStatus.currency.network.rawId == pair.from.network } val statusTo = cryptoCurrencyStatusList .firstOrNull { currencyStatus -> currencyStatus.currency.getContractAddress() == pair.to.contractAddress && - currencyStatus.currency.network.backendId == pair.to.network + currencyStatus.currency.network.rawId == pair.to.network } val mappedProviders = pair.providers @@ -143,14 +143,14 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( cryptoCurrencyList .firstOrNull { currency -> currency.getContractAddress() == pair.from.contractAddress && - currency.network.backendId == pair.from.network + currency.network.rawId == pair.from.network } } val statusToDeferred = async { cryptoCurrencyList .firstOrNull { currency -> currency.getContractAddress() == pair.to.contractAddress && - currency.network.backendId == pair.to.network + currency.network.rawId == pair.to.network } } @@ -198,10 +198,10 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( } else { null }, - fromNetwork = fromCryptoCurrency.network.backendId, + fromNetwork = fromCryptoCurrency.network.rawId, fromContractAddress = fromCryptoCurrency.getContractAddress(), fromDecimals = fromCryptoCurrency.decimals, - toNetwork = toCryptoCurrency.network.backendId, + toNetwork = toCryptoCurrency.network.rawId, toContractAddress = toCryptoCurrency.getContractAddress(), toDecimals = toCryptoCurrency.decimals, providerId = provider.providerId, @@ -255,8 +255,8 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( val response = tangemExpressApi.getExchangeData( fromContractAddress = fromCurrency.getContractAddress(), toContractAddress = toCryptoCurrency.getContractAddress(), - fromNetwork = fromCurrency.network.backendId, - toNetwork = toCryptoCurrency.network.backendId, + fromNetwork = fromCurrency.network.rawId, + toNetwork = toCryptoCurrency.network.rawId, fromAddress = fromStatus.networkAddress?.defaultAddress?.value.orEmpty(), toAddress = toAddress, fromDecimals = fromCurrency.decimals, @@ -316,7 +316,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( ), body = ExchangeSentRequestBody( txId = txId, - fromNetwork = currency.network.backendId, + fromNetwork = currency.network.rawId, fromAddress = status.networkAddress?.defaultAddress?.value.orEmpty(), payinAddress = payInAddress, payinExtraId = txExtraId, diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt index 04e3203f28..64c6dae739 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/TokenInfoConverter.kt @@ -10,7 +10,7 @@ class TokenInfoConverter : Converter { override fun convert(value: CryptoCurrency): LeastTokenInfo { return LeastTokenInfo( contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = value.network.backendId, + network = value.network.rawId, ) } diff --git a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt index 077f772d62..b04f24e429 100644 --- a/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt +++ b/data/tokens/src/main/kotlin/com/tangem/data/tokens/repository/DefaultCurrenciesRepository.kt @@ -134,7 +134,7 @@ internal class DefaultCurrenciesRepository( } override fun isNetworkFeeZero(userWalletId: UserWalletId, network: Network): Boolean { - val blockchain = Blockchain.fromNetworkId(network.backendId) + val blockchain = Blockchain.fromNetworkId(network.rawId) return blockchain?.isNetworkFeeZero() == true } } \ No newline at end of file diff --git a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt b/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt index ed5dccaddf..d7eff8a3f1 100644 --- a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt +++ b/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt @@ -183,7 +183,7 @@ internal class DefaultTokenSyncRepository( if (tokenBalances.isEmpty()) { return NetworkResult.Success( - networkId = network.backendId, + networkId = network.rawId, responseTokens = emptyList(), ) } @@ -195,11 +195,11 @@ internal class DefaultTokenSyncRepository( .map { it.toResponseToken() } NetworkResult.Success( - networkId = network.backendId, + networkId = network.rawId, responseTokens = responseTokens, ) } catch (e: Exception) { - NetworkResult.Error(networkId = network.backendId, cause = e) + NetworkResult.Error(networkId = network.rawId, cause = e) } } @@ -217,7 +217,7 @@ internal class DefaultTokenSyncRepository( val tokensToEnrich = tokenBalances.filter { !it.isNativeToken } val catalogMap = fetchCatalogInfo( - networkId = network.backendId, + networkId = network.rawId, contractAddresses = tokensToEnrich.mapNotNull(TokenBalance::contractAddress), ) @@ -240,7 +240,7 @@ internal class DefaultTokenSyncRepository( amount = balance.amount, isNativeToken = false, currencyId = coin.id, - networkId = network.backendId, + networkId = network.rawId, ) } } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt index 29395fce20..bdff8734da 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultAllowanceRepository.kt @@ -38,7 +38,7 @@ internal class DefaultAllowanceRepository( allowance >= requiredAmount -> AllowanceInfo.Enough(allowance) allowance > BigDecimal.ZERO && allowance < requiredAmount && BlockchainUtils.isTetherInEthereum( - blockchainId = cryptoCurrency.network.rawId, + networkId = cryptoCurrency.network.rawId, contractAddress = cryptoCurrency.contractAddress, ) -> AllowanceInfo.ResetNeeded(allowance, requiredAmount) else -> AllowanceInfo.NotEnough(allowance, requiredAmount) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index 06da7b3fa9..17c0b17828 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -312,7 +312,7 @@ internal class DefaultTransactionRepository( nonce: BigInteger?, gasLimit: BigInteger?, ): TransactionExtras { - val blockchain = Blockchain.fromNetworkId(networkId = network.backendId) + val blockchain = Blockchain.fromNetworkId(networkId = network.rawId) ?: error("Blockchain not found") return when { blockchain.isEvm() -> { diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt index c8491f5be6..7f934668f6 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt @@ -9,7 +9,7 @@ internal object BlockAidChainNameConverter : Converter { @Suppress("CyclomaticComplexMethod") override fun convert(value: Network): String? { - return when (Blockchain.fromNetworkId(value.backendId)) { + return when (Blockchain.fromNetworkId(value.rawId)) { Blockchain.Arbitrum -> "arbitrum" Blockchain.Avalanche -> "avalanche" Blockchain.AvalancheTestnet -> "avalanche-fuji" diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt index 22f5b7a42e..e8f613b613 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepository.kt @@ -43,7 +43,7 @@ internal class DefaultYieldSupplyRepository( private val statusMapFlow = MutableStateFlow>(emptyMap()) - override suspend fun getCachedMarkets(): List? = withContext(dispatchers.io) { + override suspend fun getCachedMarkets(): List = withContext(dispatchers.io) { val cache = store.getSyncOrNull().orEmpty() val domain = cache.map(YieldMarketTokenConverter::convert) domain.enrichNetworkIds() @@ -62,14 +62,14 @@ internal class DefaultYieldSupplyRepository( } override suspend fun getTokenStatus(cryptoCurrencyToken: CryptoCurrency.Token): YieldMarketToken { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.rawId)?.getChainId() ?: error("Chain id is required for evm's") val response = yieldSupplyApi.getYieldTokenStatus(chainId, cryptoCurrencyToken.contractAddress).getOrThrow() return YieldMarketTokenConverter.convert(response) } override suspend fun getTokenChart(cryptoCurrencyToken: CryptoCurrency.Token): YieldSupplyMarketChartData { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.rawId)?.getChainId() ?: error("Chain id is required for evm's") val response = yieldSupplyApi.getYieldTokenChart(chainId, cryptoCurrencyToken.contractAddress).getOrThrow() return YieldTokenChartConverter.convert(response) @@ -99,7 +99,7 @@ internal class DefaultYieldSupplyRepository( cryptoCurrencyToken: CryptoCurrency.Token, address: String, ): Boolean = withContext(dispatchers.io) { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.rawId)?.getChainId() ?: error("Chain id is required for evm's") yieldSupplyApi.activateYieldModule( body = YieldSupplyChangeTokenStatusBody( @@ -113,7 +113,7 @@ internal class DefaultYieldSupplyRepository( override suspend fun deactivateProtocol(cryptoCurrencyToken: CryptoCurrency.Token, address: String): Boolean = withContext(dispatchers.io) { - val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.backendId)?.getChainId() + val chainId = Blockchain.fromNetworkId(cryptoCurrencyToken.network.rawId)?.getChainId() ?: error("Chain id is required for evm's") yieldSupplyApi.deactivateYieldModule( YieldSupplyChangeTokenStatusBody( diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt index 6d5251e8df..310c568308 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt @@ -36,7 +36,7 @@ class DefaultYieldSupplyTransactionRepositoryTest { private val userWalletId = mockk() private val cryptoCurrency = mockk(relaxed = true) { every { network.id } returns networkId - every { network.backendId } returns networkId.rawId.value + every { network.rawId } returns networkId.rawId.value every { contractAddress } returns mockedContractAddress } private val cryptoCurrencyStatus = mockk(relaxed = true) { diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt index de9a515a80..b3bc1bf1c0 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCase.kt @@ -80,7 +80,7 @@ class ArchiveCryptoPortfolioUseCase( val hasNotReferralToken = statuses.none { status -> val currency = status.currency - currency.network.backendId == referralToken.networkId && + currency.network.rawId == referralToken.networkId && (currency as? CryptoCurrency.Token)?.contractAddress == referralToken.contractAddress && status.value.networkAddress?.availableAddresses?.any { it.value == address } == true } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt index 3561434daf..82f735a3c9 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/ManageCryptoCurrenciesUseCase.kt @@ -168,7 +168,7 @@ class ManageCryptoCurrenciesUseCase( val foundToken = accountStatus.tokenList.flattenCurrencies() .mapNotNull { it.currency as? CryptoCurrency.Token } .firstOrNull { token -> - token.network.backendId == networkId && + token.network.rawId == networkId && !token.isCustom && token.contractAddress.equals(contractAddress, true) } @@ -361,7 +361,7 @@ class ManageCryptoCurrenciesUseCase( launch { val assetIds = currencies.mapTo(hashSetOf()) { currency -> ExpressAsset.ID( - networkId = currency.network.backendId, + networkId = currency.network.rawId, contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, ) } @@ -388,19 +388,19 @@ class ManageCryptoCurrenciesUseCase( ) { constructor(network: Network) : this( - networkId = network.backendId, + networkId = network.rawId, derivationPath = network.derivationPath, contractAddress = null, ) constructor(currency: CryptoCurrency) : this( - networkId = currency.network.backendId, + networkId = currency.network.rawId, derivationPath = currency.network.derivationPath, contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, ) constructor(status: CryptoCurrencyStatus) : this( - networkId = status.currency.network.backendId, + networkId = status.currency.network.rawId, derivationPath = status.currency.network.derivationPath, contractAddress = (status.currency as? CryptoCurrency.Token)?.contractAddress, ) diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt index d3257d26ed..1656279ce6 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/ArchiveCryptoPortfolioUseCaseTest.kt @@ -143,7 +143,7 @@ class ArchiveCryptoPortfolioUseCaseTest { val defaultAddress = "0xABC" val cryptoCurrency = mockk { - every { this@mockk.network.backendId } returns token.networkId + every { this@mockk.network.rawId } returns token.networkId every { this@mockk.contractAddress } returns token.contractAddress!! } diff --git a/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt index e6a93c5605..eb82e7af92 100644 --- a/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt +++ b/domain/earn/src/main/java/com/tangem/domain/earn/usecase/GetEarnNetworksUseCase.kt @@ -56,7 +56,7 @@ class GetEarnNetworksUseCase( accountLists .filter { it.userWalletId in unlockedWalletsId } .flatMap(AccountList::flattenCurrencies) - .mapTo(HashSet()) { it.network.backendId } + .mapTo(HashSet()) { it.network.rawId } } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt index cc408905ee..945cc04f81 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/currency/CryptoCurrencyExtensions.kt @@ -3,7 +3,7 @@ package com.tangem.domain.models.currency import java.math.BigDecimal fun CryptoCurrency.Token.yieldSupplyKey(): String { - return "${network.backendId}_$contractAddress" + return "${network.rawId}_$contractAddress" } fun CryptoCurrencyStatus.hasNotSuppliedAmount(): Boolean { diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt index 53be78f1ff..489ede3a4d 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt @@ -35,11 +35,6 @@ data class Network( val nameResolvingType: NameResolvingType, ) { - /** Backend ID */ - @Deprecated("Will be removed later") - val backendId: String - get() = id.rawId.value - /** Raw ID */ val rawId: String get() = id.rawId.value diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt index 27a9af10ba..1cdfec9931 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt @@ -193,7 +193,7 @@ class WalletBalanceFetcher internal constructor( private suspend fun fetchExpressAssets(userWallet: UserWallet, currencies: Set) { val assetIds = currencies.mapTo(hashSetOf()) { currency -> ExpressAsset.ID( - networkId = currency.network.backendId, + networkId = currency.network.rawId, contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress, ) } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt index 1aec1f720e..ebb7a3bf0e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/GetEthSpecificFeeUseCase.kt @@ -40,8 +40,8 @@ class GetEthSpecificFeeUseCase( ?: (walletManager as? EthereumWalletManager)?.getGasPriceValue() ?: error("not supported for ${cryptoCurrency.network}") - val blockchain = Blockchain.fromNetworkId(networkId = cryptoCurrency.network.backendId) - ?: error("unknown networkId ${cryptoCurrency.network.backendId}") + val blockchain = Blockchain.fromNetworkId(networkId = cryptoCurrency.network.rawId) + ?: error("unknown networkId ${cryptoCurrency.network.rawId}") val minimalFee = getEthLegacyFee( gasPrice = gasPriceResult, diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt index b868b9b8be..e97bb37f05 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt @@ -36,13 +36,13 @@ data class AvailableToAddAccount( get() = availableNetworks.size == 1 val availableToAddNetworks: Set = availableNetworks - .filter { available -> addedNetworks.none { added -> added.backendId == available.networkId } } + .filter { available -> addedNetworks.none { added -> added.rawId == available.networkId } } .toSet() val isAvailableToAdd: Boolean = availableToAddNetworks.isNotEmpty() val addedMarketNetworks: Set = availableNetworks - .filter { available -> addedNetworks.any { added -> added.backendId == available.networkId } } + .filter { available -> addedNetworks.any { added -> added.rawId == available.networkId } } .toSet() } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index 85cb23c471..05f213308c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -526,7 +526,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( val networks = newInfo.networks?.filter { network -> BlockchainUtils.isSupportedNetworkId( - blockchainId = network.networkId, + networkId = network.networkId, excludedBlockchains = excludedBlockchains, hotExcludedBlockchains = hotWalletExcludedBlockchains, hasOnlyHotWallets = isAllWalletsIsHot, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt index e62d320665..16c044adb6 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/CustomTokenFormModel.kt @@ -183,7 +183,7 @@ internal class CustomTokenFormModel @Inject constructor( ) = modelScope.launch { val isNeedColdWalletInteraction = coldWalletAndHasMissedDerivationsUseCase.invoke( userWalletId = params.mode.userWalletId, - networksWithDerivationPath = mapOf(currency.network.backendId to getDerivationPath().value), + networksWithDerivationPath = mapOf(currency.network.rawId to getDerivationPath().value), ) state.update { state -> diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt index 2c10b94d72..e767b7ab35 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/ManageTokensModel.kt @@ -293,7 +293,7 @@ internal class ManageTokensModel @Inject constructor( val networks = currenciesToAdd.values .flatten() .toSet() - .associate { network -> network.backendId to network.derivationPath.value } + .associate { network -> network.rawId to network.derivationPath.value } val isNeedToInteractWithColdWallet = useCasesFacade.needColdWalletInteraction(networks) state.update { state -> diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt index 2e0e1b9a56..ceb222b45e 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/model/OnboardingManageTokensModel.kt @@ -212,7 +212,7 @@ internal class OnboardingManageTokensModel @Inject constructor( val network = currenciesToAdd.values .flatten() .toSet() - .associate { network -> network.backendId to network.derivationPath.value } + .associate { network -> network.rawId to network.derivationPath.value } val shouldShowTangemIcon = useCasesFacade.needColdWalletInteraction(network = network) state.update { state -> state.copy( diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt index 96cc6f1e8a..e09585fdee 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/CustomTokenFormUseCasesFacade.kt @@ -81,7 +81,7 @@ internal class CustomTokenFormUseCasesFacade @AssistedInject constructor( private fun CryptoCurrency.getAccountIndex(): Either = either { val currency = this@getAccountIndex - val blockchain = Blockchain.fromNetworkId(networkId = currency.network.backendId) + val blockchain = Blockchain.fromNetworkId(networkId = currency.network.rawId) if (blockchain == null) { val exception = IllegalStateException("Token has unknown networkId: ${currency.id}") TangemLogger.e("Error", exception) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt index 63cab69a36..6271951a73 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/list/ManageTokensUseCasesFacade.kt @@ -79,7 +79,7 @@ internal class ManageTokensUseCasesFacade @AssistedInject constructor( ?: return IllegalStateException("Account not found").left() (account.cryptoCurrencies + added - removed).any { currency -> - currency is CryptoCurrency.Token && currency.network.backendId == network.backendId && + currency is CryptoCurrency.Token && currency.network.rawId == network.rawId && currency.network.derivationPath == network.derivationPath } .right() diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt index 0432278b80..ea195b5280 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/model/OnrampAddTokenModel.kt @@ -50,7 +50,7 @@ internal class OnrampAddTokenModel @Inject constructor( .distinctUntilChanged() .mapLatest { tokenToAdd: AddHotCryptoData -> addTokenJob.join() - val backendId = tokenToAdd.cryptoCurrency.network.backendId + val backendId = tokenToAdd.cryptoCurrency.network.rawId val userWalletId = tokenToAdd.account.accountId.userWalletId val isTangemIconVisible = needColdWalletInteraction( walletId = userWalletId, diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index f3b60a2dcd..e949b69cf7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -252,7 +252,7 @@ internal class AvailableSwapPairsModel @Inject constructor( is AccountStatus.CryptoPortfolio -> { val statuses = accountStatus.tokenList.flattenCurrencies() .filterNot { status -> - status.currency.network.backendId == selectedStatus?.currency?.network?.backendId && + status.currency.network.rawId == selectedStatus?.currency?.network?.rawId && status.currency.id.contractAddress == selectedStatus.currency.id.contractAddress } .filterByQuery(query = query) @@ -462,7 +462,7 @@ internal class AvailableSwapPairsModel @Inject constructor( private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo { return LeastTokenInfo( contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = currency.network.backendId, + network = currency.network.rawId, ) } @@ -607,7 +607,7 @@ internal class AvailableSwapPairsModel @Inject constructor( val networks = tokenMarket.networks?.filter { network -> BlockchainUtils.isSupportedNetworkId( - blockchainId = network.networkId, + networkId = network.networkId, coinId = tokenMarket.id.value, contractAddress = network.contractAddress, excludedBlockchains = excludedBlockchains, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt index 5cf7dc551b..cd6ae47ea5 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/notifications/model/NotificationsModel.kt @@ -251,7 +251,7 @@ internal class NotificationsModel @Inject constructor( addExceedsBalanceNotification( cryptoCurrencyWarning = currencyWarning, cryptoCurrencyStatus = cryptoCurrencyStatus, - shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(currency.network.backendId), + shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(currency.network.rawId), onClick = ::showTokenDetails, onAnalyticsEvent = { val event = NotificationsAnalyticEvents.NoticeNotEnoughFee( diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt index a12c16b790..648df734f4 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/deeplink/DefaultStakingDeepLinkHandler.kt @@ -54,7 +54,7 @@ internal class DefaultStakingDeepLinkHandler @AssistedInject constructor( ) .orEmpty() .firstOrNull { currency -> - val isNetwork = currency.network.backendId.equals(networkId, ignoreCase = true) + val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true) val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true isNetwork && isCurrency } diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt index a46b3a7c79..2cee0bc82d 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/converters/StakingBalanceEntryConverter.kt @@ -85,7 +85,7 @@ internal class StakingBalanceEntryConverter( private fun StakingBalanceEntry.getBalanceValue(): BigDecimal { val isIncludeStakingTotalBalance = BlockchainUtils.isIncludeStakingTotalBalance( - blockchainId = cryptoCurrencyStatus.currency.network.rawId, + networkId = cryptoCurrencyStatus.currency.network.rawId, ) return if (isIncludeStakingTotalBalance) { amount diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt index 8849435172..1786ed1195 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt @@ -229,7 +229,7 @@ internal class AddStakingNotificationsTransformer( addExceedsBalanceNotification( cryptoCurrencyWarning = currencyWarning, cryptoCurrencyStatus = cryptoCurrencyStatus, - shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId), + shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(network.rawId), onClick = prevState.clickIntents::openTokenDetails, onAnalyticsEvent = { prevState.clickIntents.onNotEnoughFeeNotificationShow() }, onResetAnalyticsEvent = { /*no-op*/ }, @@ -315,7 +315,7 @@ internal class AddStakingNotificationsTransformer( currencyName = name, feeName = name, feeSymbol = symbol, - mergeFeeNetworkName = BlockchainUtils.isArbitrum(network.backendId), + mergeFeeNetworkName = BlockchainUtils.isArbitrum(network.rawId), onClick = { onClick(this) }, ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt index 3868504cd6..39b0819d33 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt @@ -29,7 +29,7 @@ internal class SwapChooseContentStateTransformer( val isMain = cryptoCurrency is CryptoCurrency.Coin val subtitle = when { - BlockchainUtils.isL2Network(networkId = network.backendId) -> MAIN_NETWORK_L2_TYPE_NAME + BlockchainUtils.isL2Network(networkId = network.rawId) -> MAIN_NETWORK_L2_TYPE_NAME isMain -> MAIN_NETWORK_TYPE_NAME network.standardType !is Network.StandardType.Unspecified -> network.standardType.name else -> "" diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt index 12dc1705f0..cbb6bd7b04 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/LeastTokenInfoConverter.kt @@ -9,7 +9,7 @@ class LeastTokenInfoConverter : Converter { override fun convert(value: CryptoCurrency): LeastTokenInfo { return LeastTokenInfo( contractAddress = (value as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = value.network.backendId, + network = value.network.rawId, ) } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index a8f4b68486..b90ffae536 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -162,7 +162,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( userWallet = userWallet, initialCurrency = LeastTokenInfo( contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = currency.network.backendId, + network = currency.network.rawId, ), currenciesList = walletAccountCurrencyStatusesExceptInitial.flatMap { accountStatus -> accountStatus.value.map { it.currency } @@ -199,7 +199,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private fun List.filterCurrencies(currency: CryptoCurrency) = this.filter { status -> - val isDifferentCurrency = status.currency.network.backendId != currency.network.backendId || + val isDifferentCurrency = status.currency.network.rawId != currency.network.rawId || status.currency.getContractAddress() != currency.getContractAddress() val hasValidStatus = @@ -218,7 +218,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ): CurrenciesGroup { val filteredPairs = leastPairs.filter { pair -> tokenInfoForFilter(pair).contractAddress == currency.getContractAddress() && - tokenInfoForFilter(pair).network == currency.network.backendId + tokenInfoForFilter(pair).network == currency.network.rawId } val accountCurrencyList = cryptoCurrenciesList.map { (accountEntry, currencyStatusList) -> @@ -259,7 +259,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( return swapPairsLeastList.firstNotNullOfOrNull { pair -> val listTokenInfo = tokenInfoForAvailable(pair) - if (cryptoCurrencyStatuses.currency.network.backendId == listTokenInfo.network && + if (cryptoCurrencyStatuses.currency.network.rawId == listTokenInfo.network && cryptoCurrencyStatuses.currency.getContractAddress() == listTokenInfo.contractAddress && isAvailableForSwap ) { @@ -359,7 +359,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( is Account.Payment -> true else -> isBalanceEnough(fromToken, amount, null) } - val networkId = fromToken.currency.network.backendId + val networkId = fromToken.currency.network.rawId return supervisorScope { providers.map { provider -> @@ -453,9 +453,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val maybeQuotes = repository.findBestQuote( userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, + fromNetwork = fromToken.currency.network.rawId, toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.backendId, + toNetwork = toToken.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.currency.decimals, @@ -527,9 +527,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val maybeQuotes = repository.findBestQuote( userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, + fromNetwork = fromToken.currency.network.rawId, toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.backendId, + toNetwork = toToken.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.currency.decimals, @@ -672,7 +672,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( FeePaidCurrency.Coin -> { val nativeBalance = walletManagersFacade.getNativeTokenBalance( userWalletId = userWalletId, - networkId = fromTokenStatus.currency.network.backendId, + networkId = fromTokenStatus.currency.network.rawId, derivationPath = fromTokenStatus.currency.network.derivationPath.value, ) @@ -780,7 +780,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - val networkId = currencyToSend.currency.network.backendId + val networkId = currencyToSend.currency.network.rawId if (isSolana(networkId)) { onSwapSolanaDex( provider = swapProvider, @@ -902,7 +902,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( repository.exchangeSent( userWallet = userWallet, txId = swapData.transaction.txId, - fromNetwork = currencyToSendStatus.currency.network.backendId, + fromNetwork = currencyToSendStatus.currency.network.rawId, fromAddress = fromAddress, payInAddress = payInAddress, txHash = txHash, @@ -968,10 +968,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( val exchangeData = repository.getExchangeData( userWallet = userWallet, fromContractAddress = currencyToSend.currency.getContractAddress(), - fromNetwork = currencyToSend.currency.network.backendId, + fromNetwork = currencyToSend.currency.network.rawId, toContractAddress = currencyToGet.currency.getContractAddress(), fromAddress = fromAddress, - toNetwork = currencyToGet.currency.network.backendId, + toNetwork = currencyToGet.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = currencyToGet.currency.decimals, @@ -1016,7 +1016,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ), exchangeData = TangemPayWithdrawExchangeState( txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.backendId, + fromNetwork = currencyToSend.currency.network.rawId, fromAddress = networkAddress?.defaultAddress?.value.orEmpty(), payInAddress = exchangeData.transaction.txTo, payInExtraId = exchangeDataCex.txExtraId, @@ -1080,7 +1080,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( repository.exchangeSent( userWallet = userWallet, txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.backendId, + fromNetwork = currencyToSend.currency.network.rawId, fromAddress = cexFromAddress, payInAddress = getPayoutAddress(txData), txHash = txHash, @@ -1222,10 +1222,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( repository.getExchangeData( userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, + fromNetwork = fromToken.currency.network.rawId, toContractAddress = toToken.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.backendId, + toNetwork = toToken.currency.network.rawId, fromAmount = swapAmount.toStringWithRightOffset(), fromDecimals = swapAmount.decimals, toDecimals = toToken.currency.decimals, @@ -1235,7 +1235,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, expressOperationType = ExpressOperationType.SWAP, ).map { swapData -> - val networkId = fromToken.currency.network.backendId + val networkId = fromToken.currency.network.rawId val transaction = swapData.transaction as ExpressTransactionModel.DEX loadFeeForDex( @@ -1361,9 +1361,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( val quotes = repository.findBestQuote( userWallet = userWallet, fromContractAddress = fromToken.getContractAddress(), - fromNetwork = fromToken.network.backendId, + fromNetwork = fromToken.network.rawId, toContractAddress = toToken.getContractAddress(), - toNetwork = toToken.network.backendId, + toNetwork = toToken.network.rawId, fromAmount = amountToRequest.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.decimals, @@ -1739,10 +1739,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( return repository.getExchangeData( userWallet = userWallet, fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.backendId, + fromNetwork = fromToken.currency.network.rawId, toContractAddress = toToken.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.backendId, + toNetwork = toToken.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, toDecimals = toToken.currency.decimals, @@ -1896,7 +1896,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ): Either = either { val nativeBalance = walletManagersFacade.getNativeTokenBalance( userWalletId = userWalletId, - networkId = network.backendId, + networkId = network.rawId, derivationPath = fromToken.network.derivationPath.value, ) @@ -2059,14 +2059,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } // setting up amount for approve with given amount for swap [SwapApproveType.Limited] - val fromAddress = requireNotNull( + requireNotNull( fromTokenStatus.value.networkAddress?.defaultAddress?.value, - ) { "networkAddress cant be null" } + ) { "networkAddress cannot be null" } val allowanceInfo = getAllowanceInfoUseCase( userWalletId = userWalletId, cryptoCurrency = fromToken, - spenderAddress = requireNotNull(spenderAddress) { "spenderAddress cant be null" }, + spenderAddress = requireNotNull(spenderAddress) { "spenderAddress cannot be null" }, requiredAmount = swapAmount.value, ).getOrNull() @@ -2229,7 +2229,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private fun createNativeAmountForDex(txValueAmount: String, network: Network): Amount { - val nativeDecimals = Blockchain.fromNetworkId(network.backendId)?.decimals() + val nativeDecimals = Blockchain.fromNetworkId(network.rawId)?.decimals() ?: error("Blockchain not found") val decimalValue = txValueAmount.toBigDecimalOrNull()?.movePointLeft(nativeDecimals) ?: error("txValue parse error") diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt index d83753ba54..0a0340e417 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt @@ -187,7 +187,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( val networks = tokenMarket.networks?.filter { network -> BlockchainUtils.isSupportedNetworkId( - blockchainId = network.networkId, + networkId = network.networkId, coinId = tokenMarket.id.value, contractAddress = network.contractAddress, excludedBlockchains = excludedBlockchains, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 41203dc1f7..5416aaad86 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -880,11 +880,11 @@ internal class SwapModel @Inject constructor( private fun sendErrorAnalyticsEvent(error: ExpressDataError, provider: SwapProvider) { val receiveToken = dataState.toCryptoCurrency?.currency?.let { currency -> - "${currency.network.backendId}:${currency.symbol}" + "${currency.network.rawId}:${currency.symbol}" } analyticsErrorEventHandler.sendErrorEvent( SwapEvents.NoticeProviderError( - sendToken = "${initialCurrencyFrom.network.backendId}:${initialCurrencyFrom.symbol}", + sendToken = "${initialCurrencyFrom.network.rawId}:${initialCurrencyFrom.symbol}", receiveToken = receiveToken.orEmpty(), provider = provider, errorCode = error.code, @@ -1174,7 +1174,7 @@ internal class SwapModel @Inject constructor( } runCatching(dispatchers.io) { swapInteractor.givePermissionToSwap( - networkId = fromToken.network.backendId, + networkId = fromToken.network.rawId, permissionOptions = PermissionOptions( approveData = approveDataModel, forTokenContractAddress = (fromToken as? CryptoCurrency.Token)?.contractAddress.orEmpty(), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 0ebbb39c1e..7df83970a1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -90,7 +90,7 @@ internal class StateBuilder( token = null, tokenIconUrl = initialCurrencyFrom.iconUrl, tokenCurrency = initialCurrencyFrom.symbol, - coinId = initialCurrencyFrom.network.backendId, + coinId = initialCurrencyFrom.network.rawId, canSelectAnotherToken = false, isNotNativeToken = initialCurrencyFrom is CryptoCurrency.Token, balance = "", @@ -108,7 +108,7 @@ internal class StateBuilder( balance = "", isNotNativeToken = initialCurrencyTo is CryptoCurrency.Token, networkIconRes = initialCurrencyTo?.let { getActiveIconRes(it.network.rawId) }, - coinId = initialCurrencyTo?.network?.backendId, + coinId = initialCurrencyTo?.network?.rawId, isBalanceHidden = true, ), fee = FeeItemState.Empty, @@ -145,7 +145,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), token = fromToken, tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.backendId, + coinId = fromToken.currency.network.rawId, isNotNativeToken = fromToken.currency is CryptoCurrency.Token, tokenCurrency = fromToken.currency.symbol, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, @@ -196,7 +196,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), token = fromToken, tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.backendId, + coinId = fromToken.currency.network.rawId, isNotNativeToken = fromToken.currency is CryptoCurrency.Token, tokenCurrency = fromToken.currency.symbol, canSelectAnotherToken = canSelectSendToken, @@ -214,7 +214,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), token = toToken, tokenIconUrl = toToken.currency.iconUrl, - coinId = toToken.currency.network.backendId, + coinId = toToken.currency.network.rawId, isNotNativeToken = toToken.currency is CryptoCurrency.Token, tokenCurrency = toToken.currency.symbol, canSelectAnotherToken = canSelectReceiveToken, @@ -266,7 +266,7 @@ internal class StateBuilder( token = uiStateHolder.sendCardData.token, tokenIconUrl = fromToken.iconUrl, tokenCurrency = fromToken.symbol, - coinId = fromToken.network.backendId, + coinId = fromToken.network.rawId, isNotNativeToken = fromToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectSendToken, balance = if (!canSelectSendToken) uiStateHolder.sendCardData.balance else "", @@ -282,7 +282,7 @@ internal class StateBuilder( token = uiStateHolder.receiveCardData.token, tokenIconUrl = toToken.iconUrl, tokenCurrency = toToken.symbol, - coinId = toToken.network.backendId, + coinId = toToken.network.rawId, isNotNativeToken = toToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectReceiveToken, balance = if (!canSelectReceiveToken) uiStateHolder.receiveCardData.balance else "", @@ -374,7 +374,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat), token = fromCurrencyStatus, tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = fromCurrencyStatus.currency.network.backendId, + coinId = fromCurrencyStatus.currency.network.rawId, isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, @@ -415,7 +415,7 @@ internal class StateBuilder( }, token = toCurrencyStatus, tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toCurrencyStatus.currency.network.backendId, + coinId = toCurrencyStatus.currency.network.rawId, isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, @@ -535,7 +535,7 @@ internal class StateBuilder( amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), token = toToken, tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toToken.currency.network.backendId, + coinId = toToken.currency.network.rawId, isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index d375886c77..3a3054beab 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -140,7 +140,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( val derivationPath = queryParams[DERIVATION_PATH_KEY] getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency -> - val isNetwork = currency.network.backendId.equals(networkId, ignoreCase = true) + val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true) val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt index a57f83eafb..802ec6e0bb 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt @@ -168,6 +168,6 @@ internal class TokenDetailsNotificationConverter( // workaround for networks that users have misunderstanding private fun CryptoCurrency.shouldMergeFeeNetworkName(): Boolean { - return Blockchain.fromNetworkId(this.network.backendId) == Blockchain.Arbitrum + return Blockchain.fromNetworkId(this.network.rawId) == Blockchain.Arbitrum } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt index d4d08e2e70..0a6847ed30 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/TokenListAnalyticsSender.kt @@ -150,7 +150,7 @@ internal class TokenListAnalyticsSender @Inject constructor( ) { // for now send only for Polkadot ecosystem blockchains // later dependency on Blockchain will be removed and use token name - when (val blockchain = Blockchain.fromNetworkId(currencyStatus.currency.network.backendId)) { + when (val blockchain = Blockchain.fromNetworkId(currencyStatus.currency.network.rawId)) { Blockchain.Polkadot, Blockchain.AlephZero, Blockchain.Kusama, diff --git a/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt index 050f700004..cd26b7f591 100644 --- a/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt +++ b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/YieldSupplyPromoBannerConverterTest.kt @@ -35,7 +35,7 @@ class YieldSupplyPromoBannerConverterTest { tokenList = tokenList, ) val converter = YieldSupplyPromoBannerConverter( - yieldModuleApyMap = mapOf("${token.network.backendId}_${token.contractAddress}" to BigDecimal("0.10")), + yieldModuleApyMap = mapOf("${token.network.rawId}_${token.contractAddress}" to BigDecimal("0.10")), shouldShowMainPromo = false, ) @@ -71,7 +71,7 @@ class YieldSupplyPromoBannerConverterTest { tokenList = ungroupedTokenList(statusActive), ) val converter = YieldSupplyPromoBannerConverter( - yieldModuleApyMap = mapOf("${token.network.backendId}_${token.contractAddress}" to BigDecimal("0.12")), + yieldModuleApyMap = mapOf("${token.network.rawId}_${token.contractAddress}" to BigDecimal("0.12")), shouldShowMainPromo = true, ) @@ -90,8 +90,8 @@ class YieldSupplyPromoBannerConverterTest { val statusBig = createLoadedStatus(token = tokenBig, amount = BigDecimal("10.00"), isYieldActive = false) val apyMap = mapOf( - "${tokenSmall.network.backendId}_${tokenSmall.contractAddress.lowercase()}" to BigDecimal("0.05"), - "${tokenBig.network.backendId}_${tokenBig.contractAddress.uppercase()}" to BigDecimal("0.15"), + "${tokenSmall.network.rawId}_${tokenSmall.contractAddress.lowercase()}" to BigDecimal("0.05"), + "${tokenBig.network.rawId}_${tokenBig.contractAddress.uppercase()}" to BigDecimal("0.15"), ) val params = TokenConverterParams.Wallet( @@ -114,7 +114,7 @@ class YieldSupplyPromoBannerConverterTest { val token = createToken(networkId = nonEvmId, rawId = nonEvmId, contract = "rAbC123") val status = createLoadedStatus(token = token, amount = BigDecimal("3"), isYieldActive = false) - val mismatchedKey = "${token.network.backendId}_${token.contractAddress.lowercase()}" + val mismatchedKey = "${token.network.rawId}_${token.contractAddress.lowercase()}" val apyMap = mapOf(mismatchedKey to BigDecimal("0.07")) val params = TokenConverterParams.Wallet( @@ -140,7 +140,7 @@ class YieldSupplyPromoBannerConverterTest { tokenList = ungroupedTokenList(status), ) val converter = YieldSupplyPromoBannerConverter( - yieldModuleApyMap = mapOf("${token.network.backendId}_${token.contractAddress}" to BigDecimal("0.10")), + yieldModuleApyMap = mapOf("${token.network.rawId}_${token.contractAddress}" to BigDecimal("0.10")), shouldShowMainPromo = true, ) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt index 122487f1e7..a08903b346 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/notifications/model/YieldSupplyNotificationsModel.kt @@ -61,7 +61,7 @@ internal class YieldSupplyNotificationsModel @Inject constructor( cryptoCurrencyWarning = cryptoCurrencyWarning, cryptoCurrencyStatus = cryptoCurrencyStatus, shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum( - blockchainId = cryptoCurrencyStatus.currency.network.backendId, + networkId = cryptoCurrencyStatus.currency.network.rawId, ), onClick = ::openTokenDetails, onAnalyticsEvent = { /*no-op*/ }, diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index cf3897b510..fe00a42eb6 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -37,103 +37,103 @@ object BlockchainUtils { } /** If current [networkId] is Bitcoin */ - fun isBitcoin(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isBitcoin(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Bitcoin || blockchain == Blockchain.BitcoinTestnet } - /** If current [networkId] is use custom fee */ - fun isUseBitcoinFeeConverter(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() - return isBitcoin(blockchainId) || blockchain == Blockchain.Fact0rn + /** Checks if the current [blockchainId] uses a custom fee converter */ + fun isUseBitcoinFeeConverter(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() + return isBitcoin(networkId) || blockchain == Blockchain.Fact0rn } - /** If current [blockchainId] is Tezos */ - fun isTezos(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + /** If current [networkId] is Tezos */ + fun isTezos(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Tezos } - fun isCardano(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isCardano(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Cardano } - /** If current [blockchainId] is BeaconChain */ - fun isBeaconChain(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + /** If current [networkId] is BeaconChain */ + fun isBeaconChain(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Binance || blockchain == Blockchain.BinanceTestnet } - /** If current [blockchainId] is Polygon */ - fun isPolygonChain(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + /** If current [networkId] is Polygon */ + fun isPolygonChain(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Polygon || blockchain == Blockchain.PolygonTestnet } - fun isTron(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isTron(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Tron || blockchain == Blockchain.TronTestnet } - fun isTon(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isTon(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.TON || blockchain == Blockchain.TONTestnet } fun isSupportedNetworkId( - blockchainId: String, + networkId: String, excludedBlockchains: ExcludedBlockchains, hotExcludedBlockchains: Set, hasOnlyHotWallets: Boolean = false, coinId: String? = null, contractAddress: String? = null, ): Boolean { - val blockchain = blockchainId.toBlockchain() ?: return false + val blockchain = networkId.toBlockchain() ?: return false if (blockchain in excludedBlockchains) return false if (hasOnlyHotWallets && blockchain in hotExcludedBlockchains) return false if (!contractAddress.isNullOrEmpty()) { if (!blockchain.canHandleTokens()) return false - if (coinId != null && !isNotBlockedByTerraV1Filter(blockchainId, coinId)) return false + if (coinId != null && !isNotBlockedByTerraV1Filter(networkId, coinId)) return false } return true } - fun isArbitrum(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isArbitrum(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Arbitrum } - fun isSolana(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isSolana(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Solana } - fun isPolkadot(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isPolkadot(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Polkadot || blockchain == Blockchain.PolkadotTestnet } - fun isCosmos(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isCosmos(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Cosmos || blockchain == Blockchain.CosmosTestnet } - fun isBSC(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isBSC(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.BSC || blockchain == Blockchain.BSCTestnet } - fun isEthereum(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isEthereum(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet } - fun isClore(blockchainId: String): Boolean { - return blockchainId.toBlockchain() == Blockchain.Clore + fun isClore(networkId: String): Boolean { + return networkId.toBlockchain() == Blockchain.Clore } data class BlockchainInfo( @@ -162,29 +162,29 @@ object BlockchainUtils { /** * Blockchains not affecting total balance counting on errors */ - fun isIncludeToBalanceOnError(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isIncludeToBalanceOnError(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return when (blockchain) { Blockchain.Binance, Blockchain.BinanceTestnet -> true else -> false } } - fun isIncludeStakingTotalBalance(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isIncludeStakingTotalBalance(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain != Blockchain.Cardano } - fun isStakingRewardUnavailable(blockchainId: String, isCoin: Boolean): Boolean { - val isP2PEthPool = isEthereum(blockchainId) && isCoin + fun isStakingRewardUnavailable(networkId: String, isCoin: Boolean): Boolean { + val isP2PEthPool = isEthereum(networkId) && isCoin - return isSolana(blockchainId) || isBSC(blockchainId) || isTon(blockchainId) || isP2PEthPool + return isSolana(networkId) || isBSC(networkId) || isTon(networkId) || isP2PEthPool } /** Checks if the blockchain uses case-insensitive contract addresses */ - fun isCaseInsensitiveContractAddress(blockchainId: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isCaseInsensitiveContractAddress(networkId: String): Boolean { + val blockchain = networkId.toBlockchain() return blockchain?.isEvm() == true } @@ -222,8 +222,8 @@ object BlockchainUtils { /** * Checks if the given coin is Tether on Ethereum network, which may require special handling in some cases. */ - fun isTetherInEthereum(blockchainId: String, contractAddress: String): Boolean { - val blockchain = blockchainId.toBlockchain() + fun isTetherInEthereum(networkId: String, contractAddress: String): Boolean { + val blockchain = networkId.toBlockchain() return (blockchain == Blockchain.Ethereum || blockchain == Blockchain.EthereumTestnet) && contractAddress.equals(TETHER_CONTRACT_ADDRESS, ignoreCase = true) } From 602e20a1d2b035234e7f05168ecebad1ae65beac Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Apr 2026 15:21:55 +0500 Subject: [PATCH 016/206] Updated on 2026-08-14 --- app/build.gradle.kts | 4 +- .../di/domain/AssetsDiscoveryDomainModule.kt | 52 ++++++++ .../tap/di/domain/TokenSyncDomainModule.kt | 50 -------- .../configs/feature_toggles_config.json | 2 +- .../local/preferences/PreferencesKeys.kt | 2 +- .../build.gradle.kts | 7 +- .../DefaultAssetsDiscoveryFacade.kt | 86 +++++++++++++ .../di/AssetsDiscoveryDataModule.kt | 61 +++++++++ .../DefaultAssetsDiscoveryRepository.kt} | 118 +++++++++--------- .../store/AssetsDiscoveryStore.kt} | 4 +- .../store/AssetsDiscoveryStoreFactory.kt} | 10 +- .../store/DefaultAssetsDiscoveryStore.kt} | 6 +- .../data/tokensync/di/TokenSyncDataModule.kt | 49 -------- .../DefaultWalletManagersFacade.kt | 12 -- .../build.gradle.kts | 5 +- .../assetsdiscovery/AssetsDiscoveryFacade.kt | 15 +++ .../model/AssetsDiscoveryProgress.kt} | 10 +- .../repository/AssetsDiscoveryRepository.kt | 25 ++++ ...owledgeAssetsDiscoveryCompletionUseCase.kt | 13 ++ .../usecase/ObserveAssetsDiscoveryUseCase.kt | 15 +++ .../usecase/StartAssetsDiscoveryUseCase.kt} | 26 ++-- .../repository/TokenSyncRepository.kt | 25 ---- .../AcknowledgeTokenSyncCompletionUseCase.kt | 13 -- .../usecase/ObserveTokenSyncUseCase.kt | 15 --- .../walletmanager/WalletManagersFacade.kt | 3 - .../preview/PreviewDetailsComponent.kt | 2 +- .../hotwallet/HotWalletFeatureToggles.kt | 2 +- features/hot-wallet/impl/build.gradle.kts | 2 +- .../DefaultHotWalletFeatureToggles.kt | 4 +- .../HotAccessCodeRequestModel.kt | 8 +- .../model/AddExistingWalletImportModel.kt | 8 +- .../forgetwallet/ForgetWalletModel.kt | 8 +- .../wallet-settings/impl/build.gradle.kts | 2 +- .../model/WalletSettingsModel.kt | 8 +- features/wallet/impl/build.gradle.kts | 2 +- .../wallet/child/wallet/model/WalletModel.kt | 12 +- .../intents/WalletWarningsClickIntents.kt | 16 +-- .../utils/WalletWarningsAnalyticsSender.kt | 2 +- .../domain/GetMultiWalletWarningsFactory.kt | 37 +++--- .../domain/WalletAdditionalInfoFactory.kt | 8 +- .../implementors/MultiWalletContentLoader.kt | 6 +- .../state/model/AssetsDiscoveryProgressUM.kt | 13 ++ .../wallet/state/model/TokenSyncProgressUM.kt | 13 -- .../wallet/state/model/WalletNotification.kt | 2 +- .../wallet/state/model/WalletState.kt | 6 +- ... SetAssetsDiscoveryProgressTransformer.kt} | 8 +- .../SetTokenListErrorTransformer.kt | 2 +- .../transformers/SetTokenListTransformer.kt | 2 +- .../UpdateWalletCardsCountTransformer.kt | 6 +- ...criber.kt => AssetsDiscoverySubscriber.kt} | 26 ++-- .../blockchainsdk/BlockchainSDKFactory.kt | 4 + .../DefaultBlockchainSDKFactory.kt | 16 +++ settings.gradle.kts | 4 +- 53 files changed, 489 insertions(+), 368 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt delete mode 100644 app/src/main/java/com/tangem/tap/di/domain/TokenSyncDomainModule.kt rename data/{tokensync => assetsdiscovery}/build.gradle.kts (78%) create mode 100644 data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/DefaultAssetsDiscoveryFacade.kt create mode 100644 data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/di/AssetsDiscoveryDataModule.kt rename data/{tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt => assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt} (72%) rename data/{tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStore.kt => assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt} (73%) rename data/{tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStoreFactory.kt => assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStoreFactory.kt} (86%) rename data/{tokensync/src/main/java/com/tangem/data/tokensync/store/DefaultTokenSyncStore.kt => assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt} (84%) delete mode 100644 data/tokensync/src/main/kotlin/com/tangem/data/tokensync/di/TokenSyncDataModule.kt rename domain/{tokensync => assetsdiscovery}/build.gradle.kts (73%) create mode 100644 domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/AssetsDiscoveryFacade.kt rename domain/{tokensync/src/main/java/com/tangem/domain/tokensync/model/TokenSyncProgress.kt => assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/model/AssetsDiscoveryProgress.kt} (56%) create mode 100644 domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt create mode 100644 domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/AcknowledgeAssetsDiscoveryCompletionUseCase.kt create mode 100644 domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/ObserveAssetsDiscoveryUseCase.kt rename domain/{tokensync/src/main/java/com/tangem/domain/tokensync/usecase/StartTokenSyncUseCase.kt => assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt} (71%) delete mode 100644 domain/tokensync/src/main/java/com/tangem/domain/tokensync/repository/TokenSyncRepository.kt delete mode 100644 domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/AcknowledgeTokenSyncCompletionUseCase.kt delete mode 100644 domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/ObserveTokenSyncUseCase.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/AssetsDiscoveryProgressUM.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TokenSyncProgressUM.kt rename features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/{SetTokenSyncProgressTransformer.kt => SetAssetsDiscoveryProgressTransformer.kt} (88%) rename features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/{TokenSyncSubscriber.kt => AssetsDiscoverySubscriber.kt} (56%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c7411dc8a..5def3358f3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -161,7 +161,7 @@ dependencies { implementation(projects.domain.hotWallet) implementation(projects.domain.news) implementation(projects.domain.earn) - implementation(projects.domain.tokensync) + implementation(projects.domain.assetsdiscovery) implementation(projects.domain.search) implementation(projects.common) @@ -192,7 +192,7 @@ dependencies { implementation(projects.data.common) implementation(projects.data.settings) implementation(projects.data.tokens) - implementation(projects.data.tokensync) + implementation(projects.data.assetsdiscovery) implementation(projects.data.txhistory) implementation(projects.data.wallets) implementation(projects.data.analytics) diff --git a/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt new file mode 100644 index 0000000000..1745d88c51 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt @@ -0,0 +1,52 @@ +package com.tangem.tap.di.domain + +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository +import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase +import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase +import com.tangem.utils.coroutines.AppCoroutineScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AssetsDiscoveryDomainModule { + + @Provides + @Singleton + fun provideObserveAssetsDiscoveryUseCase( + assetsDiscoveryRepository: AssetsDiscoveryRepository, + ): ObserveAssetsDiscoveryUseCase { + return ObserveAssetsDiscoveryUseCase( + assetsDiscoveryRepository = assetsDiscoveryRepository, + ) + } + + @Provides + @Singleton + fun provideAcknowledgeAssetsDiscoveryCompletionUseCase( + assetsDiscoveryRepository: AssetsDiscoveryRepository, + ): AcknowledgeAssetsDiscoveryCompletionUseCase { + return AcknowledgeAssetsDiscoveryCompletionUseCase( + assetsDiscoveryRepository = assetsDiscoveryRepository, + ) + } + + @Provides + @Singleton + fun provideStartAssetsDiscoveryUseCase( + assetsDiscoveryRepository: AssetsDiscoveryRepository, + manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + appCoroutineScope: AppCoroutineScope, + ): StartAssetsDiscoveryUseCase { + return StartAssetsDiscoveryUseCase( + assetsDiscoveryRepository = assetsDiscoveryRepository, + manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, + appCoroutineScope = appCoroutineScope, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/TokenSyncDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TokenSyncDomainModule.kt deleted file mode 100644 index 49da21bf6d..0000000000 --- a/app/src/main/java/com/tangem/tap/di/domain/TokenSyncDomainModule.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.tangem.tap.di.domain - -import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase -import com.tangem.domain.tokensync.repository.TokenSyncRepository -import com.tangem.domain.tokensync.usecase.AcknowledgeTokenSyncCompletionUseCase -import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase -import com.tangem.utils.coroutines.AppCoroutineScope -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object TokenSyncDomainModule { - - @Provides - @Singleton - fun provideObserveTokenSyncUseCase(tokenSyncRepository: TokenSyncRepository): ObserveTokenSyncUseCase { - return ObserveTokenSyncUseCase( - tokenSyncRepository = tokenSyncRepository, - ) - } - - @Provides - @Singleton - fun provideAcknowledgeTokenSyncCompletionUseCase( - tokenSyncRepository: TokenSyncRepository, - ): AcknowledgeTokenSyncCompletionUseCase { - return AcknowledgeTokenSyncCompletionUseCase( - tokenSyncRepository = tokenSyncRepository, - ) - } - - @Provides - @Singleton - fun provideStartTokenSyncUseCase( - tokenSyncRepository: TokenSyncRepository, - manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, - appCoroutineScope: AppCoroutineScope, - ): StartTokenSyncUseCase { - return StartTokenSyncUseCase( - tokenSyncRepository = tokenSyncRepository, - manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, - appCoroutineScope = appCoroutineScope, - ) - } -} \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 1a45030b37..0154a6d8a0 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -73,7 +73,7 @@ "version": "undefined" }, { - "name": "TOKEN_SYNC_ENABLED", + "name": "ASSETS_DISCOVERY_ENABLED", "version": "undefined" }, { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index be52c28b41..f03cdfb336 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -136,7 +136,7 @@ object PreferencesKeys { val HAS_HAD_FIRST_TOP_UP_KEY by lazy { stringPreferencesKey(name = "hasHadFirstTopUp") } - val PENDING_DISCOVERY_SYNC_KEY by lazy { stringPreferencesKey(name = "pendingDiscoverySync") } + val PENDING_ASSETS_DISCOVERY_KEY by lazy { stringPreferencesKey(name = "pendingAssetsDiscovery") } // region Notifications val NOTIFICATIONS_APPLICATION_ID_KEY by lazy { stringPreferencesKey(name = "notificationsApplicationId") } diff --git a/data/tokensync/build.gradle.kts b/data/assetsdiscovery/build.gradle.kts similarity index 78% rename from data/tokensync/build.gradle.kts rename to data/assetsdiscovery/build.gradle.kts index 973df2ab9a..d043536d23 100644 --- a/data/tokensync/build.gradle.kts +++ b/data/assetsdiscovery/build.gradle.kts @@ -7,22 +7,25 @@ plugins { } android { - namespace = "com.tangem.data.tokensync" + namespace = "com.tangem.data.assetsdiscovery" } dependencies { - api(projects.domain.tokensync) + api(projects.domain.assetsdiscovery) implementation(projects.domain.tokens) implementation(projects.domain.tokens.models) implementation(projects.domain.models) implementation(projects.domain.walletManager) implementation(projects.domain.wallets) implementation(projects.data.common) + implementation(projects.data.walletManager) implementation(projects.libs.blockchainSdk) + implementation(projects.libs.tangemSdkApi) implementation(projects.core.datasource) implementation(projects.core.utils) implementation(tangemDeps.blockchain) + implementation(tangemDeps.card.core) implementation(deps.androidx.datastore) diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/DefaultAssetsDiscoveryFacade.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/DefaultAssetsDiscoveryFacade.kt new file mode 100644 index 0000000000..412cb02e9b --- /dev/null +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/DefaultAssetsDiscoveryFacade.kt @@ -0,0 +1,86 @@ +package com.tangem.data.assetsdiscovery + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.address.AddressType +import com.tangem.blockchainsdk.BlockchainSDKFactory +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.data.walletmanager.extensions.makePublicKey +import com.tangem.domain.assetsdiscovery.AssetsDiscoveryFacade +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncStrict +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.config.curvesConfig +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class DefaultAssetsDiscoveryFacade @Inject constructor( + private val blockchainSDKFactory: BlockchainSDKFactory, + private val userWalletsListRepository: UserWalletsListRepository, + private val dispatchers: CoroutineDispatcherProvider, +) : AssetsDiscoveryFacade { + + override suspend fun getAssetsDiscoveryService( + userWalletId: UserWalletId, + network: Network, + ): AssetsDiscoveryFacade.AssetsDiscoveryServiceInfo? = withContext(dispatchers.io) { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + if (userWallet !is UserWallet.Hot) return@withContext null + + val assetsDiscoveryServiceFactory = blockchainSDKFactory.getAssetsDiscoveryServiceFactorySync() + ?: return@withContext null + + val blockchain = network.toBlockchain() + val address = makeAddress(userWallet, blockchain, network.derivationPath.value) + ?: return@withContext null + + AssetsDiscoveryFacade.AssetsDiscoveryServiceInfo( + address = address, + service = assetsDiscoveryServiceFactory.create(blockchain), + ) + } + + private fun makeAddress(hotWallet: UserWallet.Hot, blockchain: Blockchain, derivationPath: String?): String? { + val curve = hotWallet.curvesConfig.primaryCurve(blockchain) + val selectedWallet = hotWallet.wallets.orEmpty().firstOrNull { it.curve == curve } + ?: return null + + val path = derivationPath?.let { DerivationPath(rawPath = it) } + + val publicKey = if (path != null) { + makePublicKey( + seedKey = selectedWallet.publicKey, + blockchain = blockchain, + derivationPath = path, + derivedWalletKeys = selectedWallet.derivedKeys, + isWallet2 = true, + ) ?: return null + } else { + null + } + + return try { + val addresses = if (publicKey != null) { + blockchain.makeAddresses( + walletPublicKey = publicKey.blockchainKey, + pairPublicKey = null, + curve = selectedWallet.curve, + ) + } else { + blockchain.makeAddresses( + walletPublicKey = selectedWallet.publicKey, + pairPublicKey = null, + curve = selectedWallet.curve, + ) + } + addresses.find { it.type == AddressType.Default }?.value + } catch (e: Throwable) { + TangemLogger.w("Failed to derive address for $blockchain", e) + null + } + } +} \ No newline at end of file diff --git a/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/di/AssetsDiscoveryDataModule.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/di/AssetsDiscoveryDataModule.kt new file mode 100644 index 0000000000..ac451f953c --- /dev/null +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/di/AssetsDiscoveryDataModule.kt @@ -0,0 +1,61 @@ +package com.tangem.data.assetsdiscovery.di + +import com.tangem.blockchainsdk.BlockchainSDKFactory +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.assetsdiscovery.DefaultAssetsDiscoveryFacade +import com.tangem.data.assetsdiscovery.repository.DefaultAssetsDiscoveryRepository +import com.tangem.data.assetsdiscovery.store.AssetsDiscoveryStoreFactory +import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.common.network.NetworkFactory +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.assetsdiscovery.AssetsDiscoveryFacade +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object AssetsDiscoveryDataModule { + + @Provides + @Singleton + fun provideAssetsDiscoveryFacade( + blockchainSDKFactory: BlockchainSDKFactory, + userWalletsListRepository: UserWalletsListRepository, + dispatchers: CoroutineDispatcherProvider, + ): AssetsDiscoveryFacade = DefaultAssetsDiscoveryFacade( + blockchainSDKFactory = blockchainSDKFactory, + userWalletsListRepository = userWalletsListRepository, + dispatchers = dispatchers, + ) + + @Provides + @Singleton + fun provideAssetsDiscoveryRepository( + assetsDiscoveryFacade: AssetsDiscoveryFacade, + tangemTechApi: TangemTechApi, + userWalletsListRepository: UserWalletsListRepository, + networkFactory: NetworkFactory, + appPreferencesStore: AppPreferencesStore, + assetsDiscoveryStoreFactory: AssetsDiscoveryStoreFactory, + responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, + dispatchers: CoroutineDispatcherProvider, + excludedBlockchains: ExcludedBlockchains, + ): AssetsDiscoveryRepository = DefaultAssetsDiscoveryRepository( + assetsDiscoveryFacade = assetsDiscoveryFacade, + tangemTechApi = tangemTechApi, + userWalletsListRepository = userWalletsListRepository, + networkFactory = networkFactory, + appPreferencesStore = appPreferencesStore, + assetsDiscoveryStoreFactory = assetsDiscoveryStoreFactory, + responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, + dispatchers = dispatchers, + excludedBlockchains = excludedBlockchains, + ) +} \ No newline at end of file diff --git a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt similarity index 72% rename from data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt rename to data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt index d7eff8a3f1..1f05ac7237 100644 --- a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/repository/DefaultTokenSyncRepository.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/repository/DefaultAssetsDiscoveryRepository.kt @@ -1,12 +1,12 @@ -package com.tangem.data.tokensync.repository +package com.tangem.data.assetsdiscovery.repository +import com.tangem.blockchain.assetsdiscovery.models.DiscoveredAsset import com.tangem.blockchain.common.Blockchain -import com.tangem.blockchain.tokenbalance.models.TokenBalance import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.assetsdiscovery.store.AssetsDiscoveryStore +import com.tangem.data.assetsdiscovery.store.AssetsDiscoveryStoreFactory import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory import com.tangem.data.common.network.NetworkFactory -import com.tangem.data.tokensync.store.TokenSyncStore -import com.tangem.data.tokensync.store.TokenSyncStoreFactory import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.models.CoinsResponse @@ -14,15 +14,15 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectMapSync +import com.tangem.domain.assetsdiscovery.AssetsDiscoveryFacade +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.repository.TokenSyncRepository -import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.async @@ -30,42 +30,39 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import java.math.BigDecimal import java.util.concurrent.ConcurrentHashMap @Suppress("LongParameterList") -internal class DefaultTokenSyncRepository( - private val walletManagersFacade: WalletManagersFacade, +internal class DefaultAssetsDiscoveryRepository( + private val assetsDiscoveryFacade: AssetsDiscoveryFacade, private val tangemTechApi: TangemTechApi, private val userWalletsListRepository: UserWalletsListRepository, private val networkFactory: NetworkFactory, private val appPreferencesStore: AppPreferencesStore, - private val tokenSyncStoreFactory: TokenSyncStoreFactory, + private val assetsDiscoveryStoreFactory: AssetsDiscoveryStoreFactory, private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, private val dispatchers: CoroutineDispatcherProvider, private val excludedBlockchains: ExcludedBlockchains, -) : TokenSyncRepository { +) : AssetsDiscoveryRepository { - private val semaphore = Semaphore(MAX_CONCURRENT_REQUESTS) - private val progressStates = ConcurrentHashMap>() + private val progressStates = ConcurrentHashMap>() - override fun observeSyncProgress(userWalletId: UserWalletId): Flow { + override fun observeDiscoveryProgress(userWalletId: UserWalletId): Flow { return getProgressFlow(userWalletId) } override fun acknowledgeCompletion(userWalletId: UserWalletId) { val key = userWalletId.stringValue val stateFlow = progressStates[key] ?: return - stateFlow.value = TokenSyncProgress.Idle + stateFlow.value = AssetsDiscoveryProgress.Idle progressStates.remove(key, stateFlow) } override suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List { - val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId) - val storedTokens = tokenSyncStore.get() + val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId) + val storedTokens = assetsDiscoveryStore.get() if (storedTokens.isEmpty()) return emptyList() val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) @@ -77,34 +74,34 @@ internal class DefaultTokenSyncRepository( } override suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) { - val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId) - tokenSyncStore.clear() + val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId) + assetsDiscoveryStore.clear() } override suspend fun clearPendingFlag(userWalletId: UserWalletId) { setPendingFlag(userWalletId, value = false) } - override suspend fun getPendingSyncWalletIds(): List { + override suspend fun getPendingDiscoveryWalletIds(): List { val pendingMap = appPreferencesStore - .getObjectMapSync(PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY) + .getObjectMapSync(PreferencesKeys.PENDING_ASSETS_DISCOVERY_KEY) return pendingMap .filter { it.value } .map { UserWalletId(it.key) } } - override suspend fun runSync(userWalletId: UserWalletId) { + override suspend fun runDiscovery(userWalletId: UserWalletId) { val networks = getSupportedNetworks(userWalletId) if (networks.isEmpty()) return setPendingFlag(userWalletId, value = true) - val tokenSyncStore = tokenSyncStoreFactory.provide(userWalletId) - tokenSyncStore.clear() + val assetsDiscoveryStore = assetsDiscoveryStoreFactory.provide(userWalletId) + assetsDiscoveryStore.clear() val batches = networks.chunked(MAX_CONCURRENT_REQUESTS) var completedNetworks = 0 - getProgressFlow(userWalletId).value = TokenSyncProgress.InProgress( + getProgressFlow(userWalletId).value = AssetsDiscoveryProgress.InProgress( completedNetworks = 0, totalNetworks = networks.size, ) @@ -114,25 +111,23 @@ internal class DefaultTokenSyncRepository( completedNetworks = handleBatchResults( userWalletId = userWalletId, results = batchResults, - tokenSyncStore = tokenSyncStore, + assetsDiscoveryStore = assetsDiscoveryStore, completedNetworks = completedNetworks, totalNetworks = networks.size, ) } } - override suspend fun completeSync(userWalletId: UserWalletId) { + override suspend fun completeDiscovery(userWalletId: UserWalletId) { setPendingFlag(userWalletId, value = false) - getProgressFlow(userWalletId).value = TokenSyncProgress.Completed + getProgressFlow(userWalletId).value = AssetsDiscoveryProgress.Completed } private suspend fun processBatch(userWalletId: UserWalletId, batch: List): List { return coroutineScope { batch.map { network -> async(dispatchers.io) { - semaphore.withPermit { - processNetwork(userWalletId, network) - } + processNetwork(userWalletId, network) } }.awaitAll() } @@ -141,7 +136,7 @@ internal class DefaultTokenSyncRepository( private suspend fun handleBatchResults( userWalletId: UserWalletId, results: List, - tokenSyncStore: TokenSyncStore, + assetsDiscoveryStore: AssetsDiscoveryStore, completedNetworks: Int, totalNetworks: Int, ): Int { @@ -150,8 +145,8 @@ internal class DefaultTokenSyncRepository( for (result in results) { completed++ - handleNetworkResult(result, tokenSyncStore) - progressFlow.value = TokenSyncProgress.InProgress( + handleNetworkResult(result, assetsDiscoveryStore) + progressFlow.value = AssetsDiscoveryProgress.InProgress( completedNetworks = completed, totalNetworks = totalNetworks, ) @@ -160,35 +155,35 @@ internal class DefaultTokenSyncRepository( return completed } - private suspend fun handleNetworkResult(result: NetworkResult, tokenSyncStore: TokenSyncStore) { + private suspend fun handleNetworkResult(result: NetworkResult, assetsDiscoveryStore: AssetsDiscoveryStore) { when (result) { is NetworkResult.Success -> { if (result.responseTokens.isNotEmpty()) { try { - tokenSyncStore.append(result.responseTokens) + assetsDiscoveryStore.append(result.responseTokens) } catch (e: Exception) { TangemLogger.e("Failed to store discovered tokens for network: ${result.networkId}", e) } } } is NetworkResult.Error -> { - TangemLogger.e("Token sync failed for network: ${result.networkId}", result.cause) + TangemLogger.e("Assets discovery failed for network: ${result.networkId}", result.cause) } } } private suspend fun processNetwork(userWalletId: UserWalletId, network: Network): NetworkResult { return try { - val tokenBalances = fetchAndFilterTokenBalances(userWalletId, network) + val discoveredAssets = discoverAndFilterAssets(userWalletId, network) - if (tokenBalances.isEmpty()) { + if (discoveredAssets.isEmpty()) { return NetworkResult.Success( networkId = network.rawId, responseTokens = emptyList(), ) } - val enrichedTokens = enrichTokensWithCatalog(tokenBalances, network) + val enrichedTokens = enrichTokensWithCatalog(discoveredAssets, network) val responseTokens = enrichedTokens .filter { it.contractAddress != null } @@ -203,36 +198,35 @@ internal class DefaultTokenSyncRepository( } } - private suspend fun fetchAndFilterTokenBalances(userWalletId: UserWalletId, network: Network): List { - return withContext(dispatchers.io) { - walletManagersFacade.getTokenBalances(userWalletId, network) + private suspend fun discoverAndFilterAssets(userWalletId: UserWalletId, network: Network): List = + withContext(dispatchers.io) { + val providerInfo = assetsDiscoveryFacade.getAssetsDiscoveryService(userWalletId, network) + ?: return@withContext emptyList() + providerInfo.service.discoverAssets(providerInfo.address) .filter { it.amount > BigDecimal.ZERO } } - } private suspend fun enrichTokensWithCatalog( - tokenBalances: List, + assets: List, network: Network, - ): List = withContext(dispatchers.io) { - val tokensToEnrich = tokenBalances.filter { !it.isNativeToken } + ): List = withContext(dispatchers.io) { + val tokensToEnrich = assets.filterIsInstance() val catalogMap = fetchCatalogInfo( networkId = network.rawId, - contractAddresses = tokensToEnrich.mapNotNull(TokenBalance::contractAddress), + contractAddresses = tokensToEnrich.map { it.contractAddress }, ) - tokenBalances.mapNotNull { balance -> - if (balance.isNativeToken) return@mapNotNull null - - val contractAddressLower = balance.contractAddress?.lowercase() - val coin = contractAddressLower?.let { catalogMap[it] } ?: return@mapNotNull null + tokensToEnrich.mapNotNull { balance -> + val contractAddressLower = balance.contractAddress.lowercase() + val coin = catalogMap[contractAddressLower] ?: return@mapNotNull null val decimals = coin.networks .find { it.contractAddress?.lowercase() == contractAddressLower } ?.decimalCount ?.toInt() ?: 0 - DiscoveredToken( + EnrichedDiscoveredAsset( contractAddress = balance.contractAddress, symbol = coin.symbol, name = coin.name, @@ -290,23 +284,23 @@ internal class DefaultTokenSyncRepository( } } - private fun getProgressFlow(userWalletId: UserWalletId): MutableStateFlow { + private fun getProgressFlow(userWalletId: UserWalletId): MutableStateFlow { return progressStates.getOrPut(userWalletId.stringValue) { - MutableStateFlow(TokenSyncProgress.Idle) + MutableStateFlow(AssetsDiscoveryProgress.Idle) } } private suspend fun setPendingFlag(userWalletId: UserWalletId, value: Boolean) { appPreferencesStore.editData { prefs -> prefs.setObjectMap( - key = PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY, - value = prefs.getObjectMap(PreferencesKeys.PENDING_DISCOVERY_SYNC_KEY) + key = PreferencesKeys.PENDING_ASSETS_DISCOVERY_KEY, + value = prefs.getObjectMap(PreferencesKeys.PENDING_ASSETS_DISCOVERY_KEY) .plus(userWalletId.stringValue to value), ) } } - private fun DiscoveredToken.toResponseToken(): UserTokensResponse.Token { + private fun EnrichedDiscoveredAsset.toResponseToken(): UserTokensResponse.Token { return UserTokensResponse.Token( id = currencyId, networkId = networkId, @@ -317,7 +311,7 @@ internal class DefaultTokenSyncRepository( ) } - private data class DiscoveredToken( + private data class EnrichedDiscoveredAsset( val contractAddress: String?, val symbol: String, val name: String, diff --git a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStore.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt similarity index 73% rename from data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStore.kt rename to data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt index 919b551121..fa1b8c658e 100644 --- a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStore.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStore.kt @@ -1,8 +1,8 @@ -package com.tangem.data.tokensync.store +package com.tangem.data.assetsdiscovery.store import com.tangem.datasource.api.tangemTech.models.UserTokensResponse -interface TokenSyncStore { +interface AssetsDiscoveryStore { suspend fun get(): List diff --git a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStoreFactory.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStoreFactory.kt similarity index 86% rename from data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStoreFactory.kt rename to data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStoreFactory.kt index 43c191b61c..914c387d7d 100644 --- a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/TokenSyncStoreFactory.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/AssetsDiscoveryStoreFactory.kt @@ -1,4 +1,4 @@ -package com.tangem.data.tokensync.store +package com.tangem.data.assetsdiscovery.store import android.content.Context import androidx.datastore.core.DataStore @@ -17,18 +17,18 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class TokenSyncStoreFactory @Inject constructor( +class AssetsDiscoveryStoreFactory @Inject constructor( @NetworkMoshi private val moshi: Moshi, @ApplicationContext private val context: Context, private val appScope: AppCoroutineScope, ) { - private val stores = ConcurrentHashMap() + private val stores = ConcurrentHashMap() - fun provide(userWalletId: UserWalletId): TokenSyncStore { + fun provide(userWalletId: UserWalletId): AssetsDiscoveryStore { val userWalletStringId = userWalletId.formatted() return stores.computeIfAbsent(userWalletStringId) { - DefaultTokenSyncStore( + DefaultAssetsDiscoveryStore( persistenceStore = createPersistenceStore( fileName = "token_sync_$userWalletStringId", types = listTypes(), diff --git a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/DefaultTokenSyncStore.kt b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt similarity index 84% rename from data/tokensync/src/main/java/com/tangem/data/tokensync/store/DefaultTokenSyncStore.kt rename to data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt index d498d28ca3..9bc4d75f7f 100644 --- a/data/tokensync/src/main/java/com/tangem/data/tokensync/store/DefaultTokenSyncStore.kt +++ b/data/assetsdiscovery/src/main/kotlin/com/tangem/data/assetsdiscovery/store/DefaultAssetsDiscoveryStore.kt @@ -1,12 +1,12 @@ -package com.tangem.data.tokensync.store +package com.tangem.data.assetsdiscovery.store import androidx.datastore.core.DataStore import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import kotlinx.coroutines.flow.firstOrNull -internal class DefaultTokenSyncStore( +internal class DefaultAssetsDiscoveryStore( private val persistenceStore: DataStore>, -) : TokenSyncStore { +) : AssetsDiscoveryStore { override suspend fun get(): List { return persistenceStore.data.firstOrNull().orEmpty() diff --git a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/di/TokenSyncDataModule.kt b/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/di/TokenSyncDataModule.kt deleted file mode 100644 index 525a7612fa..0000000000 --- a/data/tokensync/src/main/kotlin/com/tangem/data/tokensync/di/TokenSyncDataModule.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.data.tokensync.di - -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory -import com.tangem.data.common.network.NetworkFactory -import com.tangem.data.tokensync.repository.DefaultTokenSyncRepository -import com.tangem.data.tokensync.store.TokenSyncStoreFactory -import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.tokensync.repository.TokenSyncRepository -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object TokenSyncDataModule { - - @Provides - @Singleton - fun provideTokenSyncRepository( - walletManagersFacade: WalletManagersFacade, - tangemTechApi: TangemTechApi, - userWalletsListRepository: UserWalletsListRepository, - networkFactory: NetworkFactory, - appPreferencesStore: AppPreferencesStore, - tokenSyncStoreFactory: TokenSyncStoreFactory, - responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, - dispatchers: CoroutineDispatcherProvider, - excludedBlockchains: ExcludedBlockchains, - ): TokenSyncRepository { - return DefaultTokenSyncRepository( - walletManagersFacade = walletManagersFacade, - tangemTechApi = tangemTechApi, - userWalletsListRepository = userWalletsListRepository, - networkFactory = networkFactory, - appPreferencesStore = appPreferencesStore, - tokenSyncStoreFactory = tokenSyncStoreFactory, - responseCryptoCurrenciesFactory = responseCryptoCurrenciesFactory, - dispatchers = dispatchers, - excludedBlockchains = excludedBlockchains, - ) - } -} \ No newline at end of file diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index 117bfacb59..1055b56685 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -19,7 +19,6 @@ import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.SimpleResult import com.tangem.blockchain.nft.models.NFTAsset import com.tangem.blockchain.nft.models.NFTCollection -import com.tangem.blockchain.tokenbalance.models.TokenBalance import com.tangem.blockchain.transactionhistory.models.TransactionHistoryRequest import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory import com.tangem.blockchainsdk.BlockchainSDKFactory @@ -850,17 +849,6 @@ internal class DefaultWalletManagersFacade @Inject constructor( return blockchain.getNFTExploreUrl(assetIdentifier) } - override suspend fun getTokenBalances(userWalletId: UserWalletId, network: Network): List { - val blockchain = network.toBlockchain() - val walletManager = getOrCreateWalletManager( - userWalletId = userWalletId, - blockchain = blockchain, - derivationPath = network.derivationPath.value, - ) ?: return emptyList() - val address = walletManager.wallet.address - return walletManager.getTokenBalances(address) - } - override suspend fun isAccountInitialized(userWalletId: UserWalletId, network: Network): Boolean { val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) val initializableAccountWalletManger = walletManager as? InitializableAccount ?: return true diff --git a/domain/tokensync/build.gradle.kts b/domain/assetsdiscovery/build.gradle.kts similarity index 73% rename from domain/tokensync/build.gradle.kts rename to domain/assetsdiscovery/build.gradle.kts index 35464995f3..6bea487412 100644 --- a/domain/tokensync/build.gradle.kts +++ b/domain/assetsdiscovery/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } android { - namespace = "com.tangem.domain.tokensync" + namespace = "com.tangem.domain.assetsdiscovery" } dependencies { @@ -14,6 +14,9 @@ dependencies { implementation(projects.domain.account.status) implementation(projects.core.utils) + implementation(projects.libs.blockchainSdk) + implementation(tangemDeps.blockchain) + implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) } \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/AssetsDiscoveryFacade.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/AssetsDiscoveryFacade.kt new file mode 100644 index 0000000000..ef873ecd3b --- /dev/null +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/AssetsDiscoveryFacade.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.assetsdiscovery + +import com.tangem.blockchain.assetsdiscovery.AssetsDiscoveryService +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId + +interface AssetsDiscoveryFacade { + + suspend fun getAssetsDiscoveryService(userWalletId: UserWalletId, network: Network): AssetsDiscoveryServiceInfo? + + data class AssetsDiscoveryServiceInfo( + val address: String, + val service: AssetsDiscoveryService, + ) +} \ No newline at end of file diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/model/TokenSyncProgress.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/model/AssetsDiscoveryProgress.kt similarity index 56% rename from domain/tokensync/src/main/java/com/tangem/domain/tokensync/model/TokenSyncProgress.kt rename to domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/model/AssetsDiscoveryProgress.kt index ee78b42a70..3b3d2cb7a4 100644 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/model/TokenSyncProgress.kt +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/model/AssetsDiscoveryProgress.kt @@ -1,13 +1,13 @@ -package com.tangem.domain.tokensync.model +package com.tangem.domain.assetsdiscovery.model -sealed class TokenSyncProgress { +sealed class AssetsDiscoveryProgress { - data object Idle : TokenSyncProgress() + data object Idle : AssetsDiscoveryProgress() data class InProgress( val completedNetworks: Int, val totalNetworks: Int, - ) : TokenSyncProgress() { + ) : AssetsDiscoveryProgress() { val progressPercent: Int get() = if (totalNetworks > 0) { completedNetworks * 100 / totalNetworks @@ -16,5 +16,5 @@ sealed class TokenSyncProgress { } } - data object Completed : TokenSyncProgress() + data object Completed : AssetsDiscoveryProgress() } \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt new file mode 100644 index 0000000000..cf9020c3c1 --- /dev/null +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/repository/AssetsDiscoveryRepository.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.assetsdiscovery.repository + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import kotlinx.coroutines.flow.Flow + +interface AssetsDiscoveryRepository { + + suspend fun runDiscovery(userWalletId: UserWalletId) + + suspend fun completeDiscovery(userWalletId: UserWalletId) + + suspend fun getPendingDiscoveryWalletIds(): List + + fun observeDiscoveryProgress(userWalletId: UserWalletId): Flow + + fun acknowledgeCompletion(userWalletId: UserWalletId) + + suspend fun clearPendingFlag(userWalletId: UserWalletId) + + suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List + + suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/AcknowledgeAssetsDiscoveryCompletionUseCase.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/AcknowledgeAssetsDiscoveryCompletionUseCase.kt new file mode 100644 index 0000000000..2ad99c87d0 --- /dev/null +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/AcknowledgeAssetsDiscoveryCompletionUseCase.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.assetsdiscovery.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository + +class AcknowledgeAssetsDiscoveryCompletionUseCase( + private val assetsDiscoveryRepository: AssetsDiscoveryRepository, +) { + + operator fun invoke(userWalletId: UserWalletId) { + assetsDiscoveryRepository.acknowledgeCompletion(userWalletId) + } +} \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/ObserveAssetsDiscoveryUseCase.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/ObserveAssetsDiscoveryUseCase.kt new file mode 100644 index 0000000000..1ee927739c --- /dev/null +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/ObserveAssetsDiscoveryUseCase.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.assetsdiscovery.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository +import kotlinx.coroutines.flow.Flow + +class ObserveAssetsDiscoveryUseCase( + private val assetsDiscoveryRepository: AssetsDiscoveryRepository, +) { + + operator fun invoke(userWalletId: UserWalletId): Flow { + return assetsDiscoveryRepository.observeDiscoveryProgress(userWalletId) + } +} \ No newline at end of file diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/StartTokenSyncUseCase.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt similarity index 71% rename from domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/StartTokenSyncUseCase.kt rename to domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt index d0a1efdfa5..11df5ba962 100644 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/StartTokenSyncUseCase.kt +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt @@ -1,18 +1,18 @@ -package com.tangem.domain.tokensync.usecase +package com.tangem.domain.assetsdiscovery.usecase import arrow.core.Either import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.repository.TokenSyncRepository +import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap -class StartTokenSyncUseCase( - private val tokenSyncRepository: TokenSyncRepository, +class StartAssetsDiscoveryUseCase( + private val assetsDiscoveryRepository: AssetsDiscoveryRepository, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val appCoroutineScope: AppCoroutineScope, ) { @@ -23,9 +23,9 @@ class StartTokenSyncUseCase( activeSyncJobs[userWalletId]?.cancel() activeSyncJobs[userWalletId] = appCoroutineScope.launch { try { - tokenSyncRepository.runSync(userWalletId) + assetsDiscoveryRepository.runDiscovery(userWalletId) applyDiscoveredTokens(userWalletId) - tokenSyncRepository.completeSync(userWalletId) + assetsDiscoveryRepository.completeDiscovery(userWalletId) } catch (e: Exception) { TangemLogger.e("Token sync failed for wallet: $userWalletId", e) } finally { @@ -36,18 +36,18 @@ class StartTokenSyncUseCase( suspend fun cancel(userWalletId: UserWalletId): Either = Either.catch { activeSyncJobs.remove(userWalletId)?.cancel() - tokenSyncRepository.clearPendingFlag(userWalletId) - tokenSyncRepository.clearDiscoveredTokens(userWalletId) + assetsDiscoveryRepository.clearPendingFlag(userWalletId) + assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId) } - fun applyPendingSyncs() { + fun applyPendingAssetsDiscovery() { appCoroutineScope.launch { try { - val pendingIds = tokenSyncRepository.getPendingSyncWalletIds() + val pendingIds = assetsDiscoveryRepository.getPendingDiscoveryWalletIds() for (walletId in pendingIds) { val isApplied = applyDiscoveredTokens(walletId) if (isApplied) { - tokenSyncRepository.clearPendingFlag(walletId) + assetsDiscoveryRepository.clearPendingFlag(walletId) } } } catch (e: Exception) { @@ -57,7 +57,7 @@ class StartTokenSyncUseCase( } private suspend fun applyDiscoveredTokens(userWalletId: UserWalletId): Boolean { - val currencies = tokenSyncRepository.getDiscoveredCurrencies(userWalletId) + val currencies = assetsDiscoveryRepository.getDiscoveredCurrencies(userWalletId) if (currencies.isEmpty()) return true @@ -67,7 +67,7 @@ class StartTokenSyncUseCase( add = currencies, ).fold( ifRight = { - tokenSyncRepository.clearDiscoveredTokens(userWalletId) + assetsDiscoveryRepository.clearDiscoveredTokens(userWalletId) true }, ifLeft = { error -> diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/repository/TokenSyncRepository.kt b/domain/tokensync/src/main/java/com/tangem/domain/tokensync/repository/TokenSyncRepository.kt deleted file mode 100644 index b56da08053..0000000000 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/repository/TokenSyncRepository.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.domain.tokensync.repository - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.model.TokenSyncProgress -import kotlinx.coroutines.flow.Flow - -interface TokenSyncRepository { - - suspend fun runSync(userWalletId: UserWalletId) - - suspend fun completeSync(userWalletId: UserWalletId) - - suspend fun getPendingSyncWalletIds(): List - - fun observeSyncProgress(userWalletId: UserWalletId): Flow - - fun acknowledgeCompletion(userWalletId: UserWalletId) - - suspend fun clearPendingFlag(userWalletId: UserWalletId) - - suspend fun getDiscoveredCurrencies(userWalletId: UserWalletId): List - - suspend fun clearDiscoveredTokens(userWalletId: UserWalletId) -} \ No newline at end of file diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/AcknowledgeTokenSyncCompletionUseCase.kt b/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/AcknowledgeTokenSyncCompletionUseCase.kt deleted file mode 100644 index 35be72c523..0000000000 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/AcknowledgeTokenSyncCompletionUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.tokensync.usecase - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.repository.TokenSyncRepository - -class AcknowledgeTokenSyncCompletionUseCase( - private val tokenSyncRepository: TokenSyncRepository, -) { - - operator fun invoke(userWalletId: UserWalletId) { - tokenSyncRepository.acknowledgeCompletion(userWalletId) - } -} \ No newline at end of file diff --git a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/ObserveTokenSyncUseCase.kt b/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/ObserveTokenSyncUseCase.kt deleted file mode 100644 index cddfbb9d7f..0000000000 --- a/domain/tokensync/src/main/java/com/tangem/domain/tokensync/usecase/ObserveTokenSyncUseCase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.domain.tokensync.usecase - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.repository.TokenSyncRepository -import kotlinx.coroutines.flow.Flow - -class ObserveTokenSyncUseCase( - private val tokenSyncRepository: TokenSyncRepository, -) { - - operator fun invoke(userWalletId: UserWalletId): Flow { - return tokenSyncRepository.observeSyncProgress(userWalletId) - } -} \ No newline at end of file diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index 04112d2530..af4b9462bb 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -12,7 +12,6 @@ import com.tangem.blockchain.extensions.Result import com.tangem.blockchain.extensions.SimpleResult import com.tangem.blockchain.nft.models.NFTAsset import com.tangem.blockchain.nft.models.NFTCollection -import com.tangem.blockchain.tokenbalance.models.TokenBalance import com.tangem.blockchainsdk.models.UpdateWalletManagerResult import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network @@ -282,8 +281,6 @@ interface WalletManagersFacade { suspend fun getNFTExploreUrl(network: Network, assetIdentifier: NFTAsset.Identifier): String? - suspend fun getTokenBalances(userWalletId: UserWalletId, network: Network): List - /** * If wallet manager implements [InitializableAccount] then returns [InitializableAccount.isAccountInitialized] * value. Otherwise always return true diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt index 552842c6e5..f8cde8a2e6 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt @@ -22,7 +22,7 @@ internal class PreviewDetailsComponent : DetailsComponent { router = DummyRouter(), hotWalletFeatureToggles = object : HotWalletFeatureToggles { override val isWalletCreationRestrictionEnabled: Boolean = true - override val isTokenSyncEnabled: Boolean = true + override val isAssetsDiscoveryEnabled: Boolean = true }, ).buildAll( isWalletConnectAvailable = true, diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt index 84d2ec41e4..aeb22534d7 100644 --- a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt @@ -2,5 +2,5 @@ package com.tangem.features.hotwallet interface HotWalletFeatureToggles { val isWalletCreationRestrictionEnabled: Boolean - val isTokenSyncEnabled: Boolean + val isAssetsDiscoveryEnabled: Boolean } \ No newline at end of file diff --git a/features/hot-wallet/impl/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts index 0daf9f2d1e..9d2eadef6e 100644 --- a/features/hot-wallet/impl/build.gradle.kts +++ b/features/hot-wallet/impl/build.gradle.kts @@ -38,7 +38,7 @@ dependencies { implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) implementation(projects.domain.hotWallet) - implementation(projects.domain.tokensync) + implementation(projects.domain.assetsdiscovery) /** Common */ implementation(projects.common.ui) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt index 0eefa161bd..bff219fd75 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt @@ -10,6 +10,6 @@ internal class DefaultHotWalletFeatureToggles( override val isWalletCreationRestrictionEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.HOT_WALLET_CREATION_RESTRICTION_ENABLED) - override val isTokenSyncEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TOKEN_SYNC_ENABLED) + override val isAssetsDiscoveryEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.ASSETS_DISCOVERY_ENABLED) } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt index c71cf787d8..0a4666a24b 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscoderequest/HotAccessCodeRequestModel.kt @@ -11,7 +11,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.settings.CanUseBiometryUseCase -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Attempts import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository.Companion.MAX_FAST_FORWARD_ATTEMPTS @@ -39,7 +39,7 @@ internal class HotAccessCodeRequestModel @Inject constructor( private val userWalletsListRepository: UserWalletsListRepository, private val canUseBiometryUseCase: CanUseBiometryUseCase, private val analyticsEventHandler: AnalyticsEventHandler, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { @@ -220,8 +220,8 @@ internal class HotAccessCodeRequestModel @Inject constructor( val userWallet = userWalletsListRepository.userWalletsSync() .firstOrNull { it is UserWallet.Hot && it.hotWalletId == currentRequest.hotWalletId } ?: return - if (hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase.cancel(userWallet.walletId) + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { + startAssetsDiscoveryUseCase.cancel(userWallet.walletId) } userWalletsListRepository.delete(listOf(userWallet.walletId)) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt index ed40d1e958..ce2c9cb8f9 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt @@ -17,7 +17,7 @@ import com.tangem.core.ui.message.bottomSheetMessage import com.tangem.crypto.bip39.Mnemonic import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.common.wallets.error.SaveWalletError -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.wallets.builder.HotUserWalletBuilder import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.hotwallet.HotWalletFeatureToggles @@ -42,7 +42,7 @@ internal class AddExistingWalletImportModel @Inject constructor( private val tangemHotSdk: TangemHotSdk, private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory, private val saveUserWalletUseCase: SaveWalletUseCase, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, @@ -114,8 +114,8 @@ internal class AddExistingWalletImportModel @Inject constructor( .onRight { setImportProgress(false) - if (hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase(userWallet.walletId) + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { + startAssetsDiscoveryUseCase(userWallet.walletId) } analyticsEventHandler.send( diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt index fa801e998e..fea68a0a31 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/forgetwallet/ForgetWalletModel.kt @@ -12,7 +12,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.wallets.usecase.DeleteWalletUseCase import com.tangem.features.hotwallet.ForgetWalletComponent import com.tangem.features.hotwallet.HotWalletFeatureToggles @@ -33,7 +33,7 @@ internal class ForgetWalletModel @Inject constructor( private val router: Router, private val deleteWalletUseCase: DeleteWalletUseCase, private val uiMessageSender: UiMessageSender, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { @@ -84,8 +84,8 @@ internal class ForgetWalletModel @Inject constructor( private fun forgetWallet() { modelScope.launch { - if (hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase.cancel(params.userWalletId) + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { + startAssetsDiscoveryUseCase.cancel(params.userWalletId) } val hasUserWallets = deleteWalletUseCase(params.userWalletId) diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index ec188e4f5e..51e2d587f8 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -50,7 +50,7 @@ dependencies { implementation(projects.domain.settings) implementation(projects.domain.notifications.models) implementation(projects.domain.notifications) - implementation(projects.domain.tokensync) + implementation(projects.domain.assetsdiscovery) /* AndroidX */ implementation(deps.androidx.fragment.ktx) diff --git a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt index 4391d1ca87..f40b8d6490 100644 --- a/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt +++ b/features/wallet-settings/impl/src/main/kotlin/com/tangem/feature/walletsettings/model/WalletSettingsModel.kt @@ -37,7 +37,7 @@ import com.tangem.domain.nft.EnableWalletNFTUseCase import com.tangem.domain.nft.GetWalletNFTEnabledUseCase import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.settings.repositories.PermissionRepository -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.wallets.analytics.Settings import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents.RecoveryPhraseScreenAction @@ -90,7 +90,7 @@ internal class WalletSettingsModel @Inject constructor( private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val singleAccountListSupplier: SingleAccountListSupplier, private val accountListSortingSaver: AccountListSortingSaver, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) : Model() { @@ -256,8 +256,8 @@ internal class WalletSettingsModel @Inject constructor( val userWallet = getUserWalletUseCase(params.userWalletId) .getOrNull() - if (userWallet is UserWallet.Hot && hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase.cancel(params.userWalletId) + if (userWallet is UserWallet.Hot && hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { + startAssetsDiscoveryUseCase.cancel(params.userWalletId) } val hasUserWallets = deleteWalletUseCase(params.userWalletId).getOrElse { error -> diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index d612025527..70e66ddf64 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -125,7 +125,7 @@ dependencies { implementation(projects.domain.yieldSupply.models) implementation(projects.domain.appTheme) implementation(projects.domain.appTheme.models) - implementation(projects.domain.tokensync) + implementation(projects.domain.assetsdiscovery) /** Feature Apis */ implementation(projects.features.details.api) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 0c5b95efcd..b1e648a65f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -38,7 +38,7 @@ import com.tangem.domain.settings.* import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.model.WcPairRequest -import com.tangem.domain.tokensync.usecase.StartTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.domain.wallets.usecase.* import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase @@ -126,7 +126,7 @@ internal class WalletModel @Inject constructor( private val tangemPayFeatureToggles: TangemPayFeatureToggles, private val uiMessageSender: UiMessageSender, private val hotWalletFeatureToggles: HotWalletFeatureToggles, - private val startTokenSyncUseCase: StartTokenSyncUseCase, + private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -159,7 +159,7 @@ internal class WalletModel @Inject constructor( subscribeTangemPayOnWalletState() subscribeToMainScreenQrScanning() enableNotificationsIfNeeded() - applyPendingTokenSyncs() + applyPendingAssetsDiscovery() clickIntents.initialize(innerWalletRouter, modelScope) @@ -840,9 +840,9 @@ internal class WalletModel @Inject constructor( } } - private fun applyPendingTokenSyncs() { - if (hotWalletFeatureToggles.isTokenSyncEnabled) { - startTokenSyncUseCase.applyPendingSyncs() + private fun applyPendingAssetsDiscovery() { + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { + startAssetsDiscoveryUseCase.applyPendingAssetsDiscovery() } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 665908c863..f3642a626e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -40,7 +40,7 @@ import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.Program import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.PromotionBannerClicked import com.tangem.domain.tokens.model.details.NavigationAction -import com.tangem.domain.tokensync.usecase.AcknowledgeTokenSyncCompletionUseCase +import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase import com.tangem.domain.wallets.usecase.* import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent.Basic @@ -97,9 +97,9 @@ internal interface WalletWarningsClickIntents { fun onCloseUpgradeBannerClick(userWalletId: UserWalletId) - fun onDismissTokenSyncNotification(userWalletId: UserWalletId) + fun onDismissAssetsDiscoveryNotification(userWalletId: UserWalletId) - fun onTokenSyncManageClick(userWalletId: UserWalletId) + fun onAssetsDiscoveryManageClick(userWalletId: UserWalletId) } @Suppress("LargeClass", "LongParameterList", "TooManyFunctions") @@ -132,7 +132,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( private val uiMessageSender: UiMessageSender, private val reviewManager: ReviewManager, private val closeHotWalletUpgradeBannerUseCase: CloseHotWalletUpgradeBannerUseCase, - private val acknowledgeTokenSyncCompletionUseCase: AcknowledgeTokenSyncCompletionUseCase, + private val acknowledgeAssetsDiscoveryCompletionUseCase: AcknowledgeAssetsDiscoveryCompletionUseCase, ) : BaseWalletClickIntents(), WalletWarningsClickIntents { override fun onAddBackupCardClick() { @@ -508,12 +508,12 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( } } - override fun onDismissTokenSyncNotification(userWalletId: UserWalletId) { - acknowledgeTokenSyncCompletionUseCase(userWalletId) + override fun onDismissAssetsDiscoveryNotification(userWalletId: UserWalletId) { + acknowledgeAssetsDiscoveryCompletionUseCase(userWalletId) } - override fun onTokenSyncManageClick(userWalletId: UserWalletId) { - acknowledgeTokenSyncCompletionUseCase(userWalletId) + override fun onAssetsDiscoveryManageClick(userWalletId: UserWalletId) { + acknowledgeAssetsDiscoveryCompletionUseCase(userWalletId) router.openManageTokensScreen( AccountId.forMainCryptoPortfolio(userWalletId), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt index 96f7c46639..4c57e85428 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletWarningsAnalyticsSender.kt @@ -120,7 +120,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor( is WalletNotification.Warning.TangemPayRefreshNeeded -> null is WalletNotification.Warning.TangemPayUnreachable -> null is WalletNotification.UpgradeHotWalletPromo -> null - is WalletNotification.TokenSyncCompleted -> null + is WalletNotification.AssetsDiscoveryCompleted -> null is WalletNotification.CreateTangemPayAccount -> null } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index f0d03bf32a..4dc9493025 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -27,8 +27,8 @@ import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.promo.ShouldShowPromoWalletUseCase import com.tangem.domain.promo.models.PromoId import com.tangem.domain.settings.IsReadyToShowRateAppUseCase -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase import com.tangem.feature.wallet.child.wallet.model.WalletActivationBannerType @@ -65,7 +65,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( private val shouldShowUpgradeHotWalletBannerUseCase: ShouldShowUpgradeHotWalletBannerUseCase, private val getUpgradeBannerClosureTimestampUseCase: GetUpgradeBannerClosureTimestampUseCase, private val checkHotWalletUpgradeBannerUseCase: CheckHotWalletUpgradeBannerUseCase, - private val observeTokenSyncUseCase: ObserveTokenSyncUseCase, + private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, ) { @@ -75,11 +75,12 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( val params = SingleAccountStatusListProducer.Params(userWallet.walletId) val accountStatusListFlow = accountDependencies.singleAccountStatusListSupplier(params) - val tokenSyncProgressFlow = if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) { - observeTokenSyncUseCase(userWallet.walletId).distinctUntilChanged() - } else { - flowOf(TokenSyncProgress.Idle) - } + val assetsDiscoveryProgressFlow = + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled && userWallet is UserWallet.Hot) { + observeAssetsDiscoveryUseCase(userWallet.walletId).distinctUntilChanged() + } else { + flowOf(AssetsDiscoveryProgress.Idle) + } return combine( accountStatusListFlow, @@ -96,7 +97,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( .distinctUntilChanged(), getUpgradeBannerClosureTimestampUseCase(userWallet.walletId) .distinctUntilChanged(), - tokenSyncProgressFlow, + assetsDiscoveryProgressFlow, ) { array -> array } .map { array -> val accountStatusList = array[0] as AccountStatusList @@ -108,7 +109,7 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( val shouldShowYieldPromo = array[6] as Boolean val shouldShowUpgradeBanner = array[7] as Boolean val closureTimestamp = array[8] as? Long - val tokenSyncProgress = array[9] as TokenSyncProgress + val assetsDiscoveryProgress = array[9] as AssetsDiscoveryProgress val flattenCurrencies = accountStatusList.flattenCurrencies() val paymentAccountStatus = accountStatusList.accountStatuses @@ -153,9 +154,9 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( clickIntents = clickIntents, ) - addTokenSyncCompletedNotification( + addAssetsDiscoveryCompletedNotification( userWallet = userWallet, - tokenSyncProgress = tokenSyncProgress, + assetsDiscoveryProgress = assetsDiscoveryProgress, clickIntents = clickIntents, ) @@ -404,17 +405,17 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( // } // } - private fun MutableList.addTokenSyncCompletedNotification( + private fun MutableList.addAssetsDiscoveryCompletedNotification( userWallet: UserWallet, - tokenSyncProgress: TokenSyncProgress, + assetsDiscoveryProgress: AssetsDiscoveryProgress, clickIntents: WalletClickIntents, ) { addIf( - element = WalletNotification.TokenSyncCompleted( - onCloseClick = { clickIntents.onDismissTokenSyncNotification(userWallet.walletId) }, - onManageTokensClick = { clickIntents.onTokenSyncManageClick(userWallet.walletId) }, + element = WalletNotification.AssetsDiscoveryCompleted( + onCloseClick = { clickIntents.onDismissAssetsDiscoveryNotification(userWallet.walletId) }, + onManageTokensClick = { clickIntents.onAssetsDiscoveryManageClick(userWallet.walletId) }, ), - condition = tokenSyncProgress is TokenSyncProgress.Completed, + condition = assetsDiscoveryProgress is AssetsDiscoveryProgress.Completed, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt index 838ea96770..b131f971d8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletAdditionalInfoFactory.kt @@ -10,7 +10,7 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.getCardsCount import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.impl.R -import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM +import com.tangem.feature.wallet.presentation.wallet.state.model.AssetsDiscoveryProgressUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import java.math.BigDecimal @@ -32,7 +32,7 @@ internal object WalletAdditionalInfoFactory { fun resolve( wallet: UserWallet, currencyAmount: BigDecimal? = null, - syncProgress: TokenSyncProgressUM = TokenSyncProgressUM.Idle, + syncProgress: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle, ): WalletAdditionalInfo { return when (wallet) { is UserWallet.Cold -> { @@ -46,8 +46,8 @@ internal object WalletAdditionalInfoFactory { } } - private fun UserWallet.Hot.resolveAdditionalInfo(syncProgress: TokenSyncProgressUM): WalletAdditionalInfo { - val content = if (syncProgress is TokenSyncProgressUM.InProgress) { + private fun UserWallet.Hot.resolveAdditionalInfo(syncProgress: AssetsDiscoveryProgressUM): WalletAdditionalInfo { + val content = if (syncProgress is AssetsDiscoveryProgressUM.InProgress) { WalletAdditionalInfo.Content.SyncProgress(syncProgress.progressPercent) } else { WalletAdditionalInfo.Content.Text( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt index 045a9c47bf..b6ea0fcf2a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/loaders/implementors/MultiWalletContentLoader.kt @@ -18,7 +18,7 @@ internal class MultiWalletContentLoader @AssistedInject constructor( private val walletNotificationsSubscriberFactory: WalletNotificationsSubscriber.Factory, private val multiWalletActionButtonsSubscriberFactory: MultiWalletActionButtonsSubscriber.Factory, private val tangemPayMainSubscriberFactory: TangemPayMainSubscriber.Factory, - private val tokenSyncSubscriberFactory: TokenSyncSubscriber.Factory, + private val assetsDiscoverySubscriberFactory: AssetsDiscoverySubscriber.Factory, private val tokenListAnalyticsSubscriberFactory: TokenListAnalyticsSubscriber.Factory, private val designFeatureToggles: DesignFeatureToggles, private val hotWalletFeatureToggles: HotWalletFeatureToggles, @@ -36,8 +36,8 @@ internal class MultiWalletContentLoader @AssistedInject constructor( multiWalletActionButtonsSubscriberFactory.create(userWallet), tangemPayMainSubscriberFactory.create(userWallet), tokenListAnalyticsSubscriberFactory.create(userWallet), - if (hotWalletFeatureToggles.isTokenSyncEnabled && userWallet is UserWallet.Hot) { - tokenSyncSubscriberFactory.create(userWallet) + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled && userWallet is UserWallet.Hot) { + assetsDiscoverySubscriberFactory.create(userWallet) } else { null }, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/AssetsDiscoveryProgressUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/AssetsDiscoveryProgressUM.kt new file mode 100644 index 0000000000..e66ccd7258 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/AssetsDiscoveryProgressUM.kt @@ -0,0 +1,13 @@ +package com.tangem.feature.wallet.presentation.wallet.state.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed class AssetsDiscoveryProgressUM { + + data object Idle : AssetsDiscoveryProgressUM() + + data class InProgress(val progressPercent: Int) : AssetsDiscoveryProgressUM() + + data object Completed : AssetsDiscoveryProgressUM() +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TokenSyncProgressUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TokenSyncProgressUM.kt deleted file mode 100644 index 2bcd1e9487..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TokenSyncProgressUM.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.model - -import androidx.compose.runtime.Immutable - -@Immutable -internal sealed class TokenSyncProgressUM { - - data object Idle : TokenSyncProgressUM() - - data class InProgress(val progressPercent: Int) : TokenSyncProgressUM() - - data object Completed : TokenSyncProgressUM() -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt index c960e5ba67..ecdd0aa9e5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotification.kt @@ -435,7 +435,7 @@ sealed class WalletNotification(val config: NotificationConfig) { ), ) - data class TokenSyncCompleted( + data class AssetsDiscoveryCompleted( val onCloseClick: () -> Unit, val onManageTokensClick: () -> Unit, ) : WalletNotification( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt index 3903da422a..82b4da90cc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt @@ -27,7 +27,7 @@ internal sealed interface WalletState : WalletStateHolder { abstract val tangemPayState: TangemPayState abstract val tangemPayMainUM: TangemPayMainUM abstract val isTangemPayRefactorEnabled: Boolean // TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED - abstract val tokenSyncProgressUM: TokenSyncProgressUM + abstract val assetsDiscoveryProgressUM: AssetsDiscoveryProgressUM data class Content( override val pullToRefreshConfig: PullToRefreshConfig, @@ -41,7 +41,7 @@ internal sealed interface WalletState : WalletStateHolder { override val tangemPayState: TangemPayState, override val tangemPayMainUM: TangemPayMainUM, override val isTangemPayRefactorEnabled: Boolean, - override val tokenSyncProgressUM: TokenSyncProgressUM = TokenSyncProgressUM.Idle, + override val assetsDiscoveryProgressUM: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle, ) : MultiCurrency() data class Locked( @@ -63,7 +63,7 @@ internal sealed interface WalletState : WalletStateHolder { override val tangemPayState: TangemPayState = TangemPayState.Empty override val tangemPayMainUM: TangemPayMainUM = TangemPayMainUM.Empty override val isTangemPayRefactorEnabled: Boolean = false - override val tokenSyncProgressUM: TokenSyncProgressUM = TokenSyncProgressUM.Idle + override val assetsDiscoveryProgressUM: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenSyncProgressTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt similarity index 88% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenSyncProgressTransformer.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt index 0dfbaf6dd9..e5dbe43b05 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenSyncProgressTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt @@ -2,21 +2,21 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory -import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM +import com.tangem.feature.wallet.presentation.wallet.state.model.AssetsDiscoveryProgressUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM -internal class SetTokenSyncProgressTransformer( +internal class SetAssetsDiscoveryProgressTransformer( private val userWallet: UserWallet, - private val progress: TokenSyncProgressUM, + private val progress: AssetsDiscoveryProgressUM, ) : WalletStateTransformer(userWallet.walletId) { override fun transform(prevState: WalletState): WalletState { return when (prevState) { is WalletState.MultiCurrency.Content -> prevState.copy( walletCardState = updateCardState(prevState.walletCardState), - tokenSyncProgressUM = progress, + assetsDiscoveryProgressUM = progress, ) else -> prevState } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt index ef5698e378..06d71e8d28 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt @@ -61,7 +61,7 @@ internal class SetTokenListErrorTransformer( walletsBalanceUM = walletUM.walletsBalanceUM.toLoadedState(), tokensListUM = WalletTokensListUM.Empty( onEmptyClick = { - clickIntents.onTokenSyncManageClick(walletUM.walletsBalanceUM.id) + clickIntents.onAssetsDiscoveryManageClick(walletUM.walletsBalanceUM.id) }, ), buttons = walletUM.disableButtons(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt index 35b381f033..f3d9013ff0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt @@ -123,7 +123,7 @@ internal class SetTokenListTransformer( if (params !is TokenConverterParams.Account) { return WalletTokensListUM.Empty( onEmptyClick = { - clickIntents.onTokenSyncManageClick(userWallet.walletId) + clickIntents.onAssetsDiscoveryManageClick(userWallet.walletId) }, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt index 86cc55373e..19d722f039 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UpdateWalletCardsCountTransformer.kt @@ -4,7 +4,7 @@ import com.tangem.domain.card.common.util.getCardsCount import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver -import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM +import com.tangem.feature.wallet.presentation.wallet.state.model.AssetsDiscoveryProgressUM import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM @@ -19,7 +19,7 @@ internal class UpdateWalletCardsCountTransformer( return when (prevState) { is WalletState.MultiCurrency.Content -> { prevState.copy( - walletCardState = prevState.walletCardState.toUpdatedState(prevState.tokenSyncProgressUM), + walletCardState = prevState.walletCardState.toUpdatedState(prevState.assetsDiscoveryProgressUM), ) } is WalletState.SingleCurrency.Content -> { @@ -39,7 +39,7 @@ internal class UpdateWalletCardsCountTransformer( } private fun WalletCardState.toUpdatedState( - syncProgress: TokenSyncProgressUM = TokenSyncProgressUM.Idle, + syncProgress: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle, ): WalletCardState { return when (this) { is WalletCardState.Content -> copy( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TokenSyncSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AssetsDiscoverySubscriber.kt similarity index 56% rename from features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TokenSyncSubscriber.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AssetsDiscoverySubscriber.kt index 1095805674..ba021b62af 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TokenSyncSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AssetsDiscoverySubscriber.kt @@ -1,11 +1,11 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase +import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress +import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController -import com.tangem.feature.wallet.presentation.wallet.state.model.TokenSyncProgressUM -import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTokenSyncProgressTransformer +import com.tangem.feature.wallet.presentation.wallet.state.model.AssetsDiscoveryProgressUM +import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetAssetsDiscoveryProgressTransformer import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -13,25 +13,25 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.onEach -internal class TokenSyncSubscriber @AssistedInject constructor( +internal class AssetsDiscoverySubscriber @AssistedInject constructor( @Assisted private val userWallet: UserWallet, private val stateController: WalletStateController, - private val observeTokenSyncUseCase: ObserveTokenSyncUseCase, + private val observeAssetsDiscoveryUseCase: ObserveAssetsDiscoveryUseCase, ) : WalletSubscriber() { override fun create(coroutineScope: CoroutineScope): Flow<*> { - return observeTokenSyncUseCase(userWallet.walletId) + return observeAssetsDiscoveryUseCase(userWallet.walletId) .onEach { current -> handleProgress(userWallet, current) } } - private fun handleProgress(userWallet: UserWallet, current: TokenSyncProgress) { + private fun handleProgress(userWallet: UserWallet, current: AssetsDiscoveryProgress) { val progressUM = when (current) { - is TokenSyncProgress.InProgress -> TokenSyncProgressUM.InProgress(current.progressPercent) - is TokenSyncProgress.Completed -> TokenSyncProgressUM.Completed - is TokenSyncProgress.Idle -> TokenSyncProgressUM.Idle + is AssetsDiscoveryProgress.InProgress -> AssetsDiscoveryProgressUM.InProgress(current.progressPercent) + is AssetsDiscoveryProgress.Completed -> AssetsDiscoveryProgressUM.Completed + is AssetsDiscoveryProgress.Idle -> AssetsDiscoveryProgressUM.Idle } stateController.update( - SetTokenSyncProgressTransformer( + SetAssetsDiscoveryProgressTransformer( userWallet = userWallet, progress = progressUM, ), @@ -40,6 +40,6 @@ internal class TokenSyncSubscriber @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(userWallet: UserWallet): TokenSyncSubscriber + fun create(userWallet: UserWallet): AssetsDiscoverySubscriber } } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/BlockchainSDKFactory.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/BlockchainSDKFactory.kt index ffe4eec03d..cffad48379 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/BlockchainSDKFactory.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/BlockchainSDKFactory.kt @@ -1,5 +1,6 @@ package com.tangem.blockchainsdk +import com.tangem.blockchain.assetsdiscovery.AssetsDiscoveryServiceFactory import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.blockchain.common.memo.MemoValidatorFactory @@ -18,4 +19,7 @@ interface BlockchainSDKFactory { /** Get [MemoValidatorFactory] synchronously */ suspend fun getMemoValidatorFactorySync(): MemoValidatorFactory? + + /** Get [AssetsDiscoveryServiceFactory] synchronously */ + suspend fun getAssetsDiscoveryServiceFactorySync(): AssetsDiscoveryServiceFactory? } \ No newline at end of file diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt index ed8efd0ebe..f08da11c5a 100644 --- a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/DefaultBlockchainSDKFactory.kt @@ -1,5 +1,6 @@ package com.tangem.blockchainsdk +import com.tangem.blockchain.assetsdiscovery.AssetsDiscoveryServiceFactory import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.blockchain.common.WalletManagerFactory import com.tangem.blockchain.common.memo.MemoValidatorFactory @@ -34,6 +35,8 @@ internal class DefaultBlockchainSDKFactory( private val walletManagerFactory: Flow = createWalletManagerFactory() private val memoValidatorFactory: Flow = createMemoValidatorFactory() + private val assetsDiscoveryServiceFactory: Flow = + createAssetsDiscoveryServiceFactory() override suspend fun init() { coroutineScope { @@ -45,6 +48,9 @@ internal class DefaultBlockchainSDKFactory( override suspend fun getMemoValidatorFactorySync(): MemoValidatorFactory? = memoValidatorFactory.firstOrNull() + override suspend fun getAssetsDiscoveryServiceFactorySync(): AssetsDiscoveryServiceFactory? = + assetsDiscoveryServiceFactory.firstOrNull() + private fun createWalletManagerFactory(): Flow { return combine( flow = flowOf(blockchainSdkConfig), @@ -56,6 +62,16 @@ internal class DefaultBlockchainSDKFactory( .stateIn(scope = mainScope, started = SharingStarted.Eagerly, initialValue = null) } + private fun createAssetsDiscoveryServiceFactory(): Flow { + return combine( + flow = flowOf(blockchainSdkConfig), + flow2 = blockchainProvidersTypesManager.get(), + ) { config, providerTypes -> + AssetsDiscoveryServiceFactory(config = config, providerTypes = providerTypes) + } + .stateIn(scope = mainScope, started = SharingStarted.Eagerly, initialValue = null) + } + private fun createMemoValidatorFactory(): Flow { return combine( flow = flowOf(blockchainSdkConfig), diff --git a/settings.gradle.kts b/settings.gradle.kts index 9d4a5fcb46..35213a3a98 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -340,7 +340,7 @@ include(":domain:dynamic-addresses:models") include(":domain:settings") include(":domain:tokens") include(":domain:tokens:models") -include(":domain:tokensync") +include(":domain:assetsdiscovery") include(":domain:wallets") include(":domain:wallets:models") include(":domain:txhistory") @@ -409,7 +409,7 @@ include(":data:balance-hiding") include(":data:common") include(":data:card") include(":data:tokens") -include(":data:tokensync") +include(":data:assetsdiscovery") include(":data:settings") include(":data:txhistory") include(":data:wallets") From c83a46cc1ff099924b46dc3ae24a02cf06aa0906 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Apr 2026 13:28:12 +0200 Subject: [PATCH 017/206] Updated on 2026-08-14 --- .../marketprice/MarketPriceBlock.kt | 1 + .../marketprice/PriceChangeInPercent.kt | 129 +++++++++- .../marketprice/PriceChangeState.kt | 9 +- .../main/res/drawable/ic_alert_triange_24.xml | 10 + .../main/res/drawable/ic_chewron_up_20.xml | 9 + .../features/feed/model/search/SearchModel.kt | 14 ++ .../converter/UserAssetSearchItemConverter.kt | 130 ++++++++-- .../feed/ui/components/LayeringIcons.kt | 99 ++++++++ .../features/feed/ui/search/SearchContent.kt | 191 +++++++------- .../ui/search/components/BalanceColumn.kt | 175 +++++++++++++ .../search/components/GroupedUserAssetItem.kt | 84 +++++++ .../search/components/SingleUserAssetItem.kt | 117 +++++++++ .../ui/search/preview/SearchContentPreview.kt | 11 +- .../ui/search/preview/UserAssetItemPreview.kt | 238 ++++++++++++++++++ .../features/feed/ui/search/state/SearchUM.kt | 35 ++- .../utils/EntryContentAnimationTransitions.kt | 6 +- .../block/impl/ui/TokenMarketBlockLegacy.kt | 1 + 17 files changed, 1116 insertions(+), 143 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_alert_triange_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_chewron_up_20.xml create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/LayeringIcons.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/BalanceColumn.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt index 92785ec693..dc99632334 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/MarketPriceBlock.kt @@ -121,6 +121,7 @@ private fun PriceBlock(state: MarketPriceBlockState, priceWidthDp: Dp) { PriceChangeInPercent( valueInPercent = marketPriceBlockState.priceChangeConfig.valueInPercent, type = marketPriceBlockState.priceChangeConfig.type, + textStyle = TangemTheme.typography.body2, ) } } else { diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt index a202d8bf21..c4882dcdc8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeInPercent.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* 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.vector.ImageVector @@ -13,15 +14,43 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R +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 @Composable fun PriceChangeInPercent( valueInPercent: String, type: PriceChangeType, + textStyle: TextStyle, + modifier: Modifier = Modifier, + isDisabled: Boolean = false, +) { + if (LocalRedesignEnabled.current) { + PriceChangeInPercentV2( + modifier = modifier, + valueInPercent = valueInPercent, + type = type, + textStyle = textStyle, + isDisabled = isDisabled, + ) + } else { + PriceChangeInPercentV1( + modifier = modifier, + valueInPercent = valueInPercent, + type = type, + textStyle = textStyle, + ) + } +} + +@Composable +private fun PriceChangeInPercentV1( + valueInPercent: String, + type: PriceChangeType, + textStyle: TextStyle, modifier: Modifier = Modifier, - textStyle: TextStyle = TangemTheme.typography.body2, ) { if (valueInPercent.isBlank()) { Box(modifier) @@ -66,25 +95,87 @@ fun PriceChangeInPercent( } } +@Composable +private fun PriceChangeInPercentV2( + valueInPercent: String, + type: PriceChangeType, + textStyle: TextStyle, + isDisabled: Boolean, + modifier: Modifier = Modifier, +) { + if (valueInPercent.isBlank()) { + Box(modifier) + return + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens2.x0_5), + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens2.x3) + .align(Alignment.CenterVertically), + imageVector = ImageVector.vectorResource( + id = when (type) { + PriceChangeType.UP -> R.drawable.ic_arrow_up_8 + PriceChangeType.DOWN -> R.drawable.ic_arrow_down_8 + PriceChangeType.NEUTRAL -> R.drawable.ic_elipse_8 + }, + ), + tint = if (isDisabled) { + TangemTheme.colors2.graphic.neutral.tertiary + } else { + when (type) { + PriceChangeType.UP -> TangemTheme.colors2.markers.iconBlue + PriceChangeType.DOWN -> TangemTheme.colors2.markers.iconRed + PriceChangeType.NEUTRAL -> TangemTheme.colors2.markers.iconGray + } + }, + contentDescription = null, + ) + + Text( + text = valueInPercent, + color = if (isDisabled) { + TangemTheme.colors2.text.status.disabled + } else { + when (type) { + PriceChangeType.UP -> TangemTheme.colors2.text.status.accent + PriceChangeType.DOWN -> TangemTheme.colors2.text.status.warning + PriceChangeType.NEUTRAL -> TangemTheme.colors2.text.neutral.tertiary + } + }, + style = textStyle, + overflow = TextOverflow.Visible, + maxLines = 1, + ) + } +} + //region Preview @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun Preview() { +private fun PreviewV1() { TangemThemePreview { Column { PriceChangeInPercent( valueInPercent = "52.00%", type = PriceChangeType.NEUTRAL, + textStyle = TangemTheme.typography.body2, ) PriceChangeInPercent( valueInPercent = "52.00%", type = PriceChangeType.UP, + textStyle = TangemTheme.typography.body2, ) PriceChangeInPercent( valueInPercent = "52.00%", type = PriceChangeType.DOWN, + textStyle = TangemTheme.typography.body2, ) PriceChangeInPercent( valueInPercent = "52.00%", @@ -95,4 +186,38 @@ private fun Preview() { } } +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewV2() { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + Column { + PriceChangeInPercent( + valueInPercent = "52.00%", + type = PriceChangeType.NEUTRAL, + textStyle = TangemTheme.typography2.captionRegular12, + ) + PriceChangeInPercent( + valueInPercent = "52.00%", + type = PriceChangeType.UP, + textStyle = TangemTheme.typography2.captionRegular12, + isDisabled = true, + ) + PriceChangeInPercent( + valueInPercent = "52.00%", + type = PriceChangeType.DOWN, + textStyle = TangemTheme.typography2.captionRegular12, + isDisabled = false, + ) + PriceChangeInPercent( + valueInPercent = "52.00%", + type = PriceChangeType.DOWN, + textStyle = TangemTheme.typography2.captionRegular12, + ) + } + } + } +} + //endregion Preview \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt index 8db483f50c..768c0696cc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/marketprice/PriceChangeState.kt @@ -1,8 +1,11 @@ package com.tangem.core.ui.components.marketprice -sealed class PriceChangeState { +import androidx.compose.runtime.Immutable - data class Content(val valueInPercent: String, val type: PriceChangeType) : PriceChangeState() +@Immutable +sealed interface PriceChangeState { - object Unknown : PriceChangeState() + data class Content(val valueInPercent: String, val type: PriceChangeType) : PriceChangeState + + data object Unknown : PriceChangeState } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_alert_triange_24.xml b/core/ui/src/main/res/drawable/ic_alert_triange_24.xml new file mode 100644 index 0000000000..6442020ee2 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_alert_triange_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_chewron_up_20.xml b/core/ui/src/main/res/drawable/ic_chewron_up_20.xml new file mode 100644 index 0000000000..aeb0f01317 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_chewron_up_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt index f43e8c5766..c55b8f0ec8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt @@ -1,6 +1,8 @@ package com.tangem.features.feed.model.search import arrow.core.getOrElse +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter import com.tangem.common.ui.charts.state.sorted @@ -19,6 +21,7 @@ import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase import com.tangem.domain.search.usecase.GetSearchResultsUseCase import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase +import com.tangem.domain.search.model.UserAssetSearchEntry import com.tangem.domain.search.usecase.SaveSearchQueryUseCase import com.tangem.features.feed.components.search.DefaultSearchComponent import com.tangem.features.feed.model.market.list.state.MarketsListUM @@ -58,6 +61,7 @@ internal class SearchModel @Inject constructor( private val saveRecentSearchTokenUseCase: SaveRecentSearchTokenUseCase, private val clearSearchHistoryUseCase: ClearSearchHistoryUseCase, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, + private val appRouter: AppRouter, private val stateController: SearchStateController, ) : Model() { @@ -195,6 +199,15 @@ internal class SearchModel @Inject constructor( stateController.update(UpdateSearchBarQueryTransformer("")) } + private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) { + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = entry.userWalletId, + currency = entry.currencyStatus.currency, + ), + ) + } + private fun subscribeToQueryChanges() { stateController.uiState .map { it.searchBar.query.trim() } @@ -231,6 +244,7 @@ internal class SearchModel @Inject constructor( val converter = UserAssetSearchItemConverter( appCurrency = appCurrency, isBalanceHidden = balanceHidden, + onSingleClick = ::onSingleUserAssetClick, ) searchResult.userAssets .map(converter::convert) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt index 4e742afdde..6f0134ca29 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt @@ -1,23 +1,34 @@ package com.tangem.features.feed.model.search.converter import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.search.model.UserAssetSearchEntry import com.tangem.domain.search.model.UserAssetSearchItem +import com.tangem.features.feed.ui.search.state.BalanceDisplayState import com.tangem.features.feed.ui.search.state.UserAssetItemUM import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal internal class UserAssetSearchItemConverter( private val appCurrency: AppCurrency, private val isBalanceHidden: Boolean, + private val onSingleClick: (UserAssetSearchEntry) -> Unit, ) : Converter { override fun convert(value: UserAssetSearchItem): UserAssetItemUM { @@ -39,17 +50,56 @@ internal class UserAssetSearchItemConverter( tokenName = currency.name, tokenSymbol = currency.symbol, fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) }, - cryptoBalance = formatCryptoAmount(value.amount, currency.symbol, currency.decimals), - fiatBalance = formatFiatAmount(value.fiatAmount), + priceChangeState = when (value) { + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAmount, + -> PriceChangeState.Unknown + else -> PriceChangeState.Content( + type = PriceChangeType.fromBigDecimal(value.priceChange.orZero()), + valueInPercent = value.priceChange.format { percent() }, + ) + }, + balanceState = convertSingleBalanceState(value, currency.symbol, currency.decimals), isBalanceHidden = isBalanceHidden, - onClick = {}, + onClick = { onSingleClick(entry) }, ) } - private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped { - val totalFiat = item.entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } - val totalCrypto = item.entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO } + private fun convertSingleBalanceState( + value: CryptoCurrencyStatus.Value, + symbol: String, + decimals: Int, + ): BalanceDisplayState { + return when { + value is CryptoCurrencyStatus.Loading && value.amount != null -> + BalanceDisplayState.Flickering( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated( + appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol, + ) ?: stringReference(StringsSigns.DASH_SIGN), + ) + value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading + value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable + value.isError && value.amount != null -> + BalanceDisplayState.Stale( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated( + appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol, + ) ?: stringReference(StringsSigns.DASH_SIGN), + ) + value.isError -> BalanceDisplayState.Unreachable + else -> BalanceDisplayState.Loaded( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = value.fiatAmount?.toMarketsListItemPriceAnnotated( + appCurrencyCode = appCurrency.code, appCurrencySymbol = appCurrency.symbol, + ) ?: stringReference(StringsSigns.DASH_SIGN), + ) + } + } + private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped { val firstCurrency = item.entries.first().currencyStatus.currency val children = item.entries.map { entry -> UserAssetItemUM.GroupedChild( @@ -57,36 +107,78 @@ internal class UserAssetSearchItemConverter( accountName = entry.accountName.toUM(), accountIcon = entry.accountIcon.value, accountColor = entry.accountIcon.color, - cryptoBalance = formatCryptoAmount( - entry.currencyStatus.value.amount, - entry.currencyStatus.currency.symbol, - entry.currencyStatus.currency.decimals, - ), - fiatBalance = formatFiatAmount(entry.currencyStatus.value.fiatAmount), + currencyStatus = entry.currencyStatus, ) }.toImmutableList() + val entryCurrencyStatus = item.entries.first().currencyStatus + return UserAssetItemUM.Grouped( id = "grouped_${item.tokenName}_${item.tokenSymbol}", icon = TangemIconUM.Currency( - currencyIconState = CryptoCurrencyToIconStateConverter().convert(item.entries.first().currencyStatus), + currencyIconState = CurrencyIconState.CoinIcon( + url = entryCurrencyStatus.currency.iconUrl, + fallbackResId = entryCurrencyStatus.currency.networkIconResId, + isGrayscale = entryCurrencyStatus.currency.network.isTestnet || entryCurrencyStatus.value.isError, + shouldShowCustomBadge = entryCurrencyStatus.currency.isCustom, + ), ), tokenName = item.tokenName, tokenSymbol = item.tokenSymbol, tokensCount = item.entries.size, - totalCryptoBalance = formatCryptoAmount(totalCrypto, firstCurrency.symbol, firstCurrency.decimals), - totalFiatBalance = formatFiatAmount(totalFiat), + balanceState = convertGroupedBalanceState(item.entries, firstCurrency.symbol, firstCurrency.decimals), isBalanceHidden = isBalanceHidden, children = children, onClick = {}, ) } + private fun convertGroupedBalanceState( + entries: List, + symbol: String, + decimals: Int, + ): BalanceDisplayState { + val hasAnyLoading = entries.any { it.currencyStatus.value is CryptoCurrencyStatus.Loading } + val hasAnyError = entries.any { it.currencyStatus.value.isError } + val hasAnyAmount = entries.any { it.currencyStatus.value.amount != null } + + val balance = when { + hasAnyLoading && !hasAnyAmount -> BalanceDisplayState.Loading + hasAnyLoading && hasAnyAmount -> computeGroupBalanceFlickering(entries, symbol, decimals) + hasAnyError && entries.size == 1 && !hasAnyAmount -> BalanceDisplayState.Unreachable + hasAnyError && entries.size > 1 -> computeGroupBalance(entries, symbol, decimals) + else -> computeGroupBalance(entries, symbol, decimals) + } + return balance + } + + private fun computeGroupBalance( + entries: List, + symbol: String, + decimals: Int, + ): BalanceDisplayState.Loaded { + val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO } + return BalanceDisplayState.Loaded( + cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)), + fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol), + ) + } + + private fun computeGroupBalanceFlickering( + entries: List, + symbol: String, + decimals: Int, + ): BalanceDisplayState.Flickering { + val totalFiat = entries.sumOf { it.currencyStatus.value.fiatAmount ?: BigDecimal.ZERO } + val totalCrypto = entries.sumOf { it.currencyStatus.value.amount ?: BigDecimal.ZERO } + return BalanceDisplayState.Flickering( + cryptoBalance = stringReference(formatCryptoAmount(totalCrypto, symbol, decimals)), + fiatBalance = totalFiat.toMarketsListItemPriceAnnotated(appCurrency.code, appCurrency.symbol), + ) + } + private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String { return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN } - - private fun formatFiatAmount(fiatAmount: BigDecimal?): String { - return fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } ?: StringsSigns.DASH_SIGN - } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/LayeringIcons.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/LayeringIcons.kt new file mode 100644 index 0000000000..1236344817 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/LayeringIcons.kt @@ -0,0 +1,99 @@ +package com.tangem.features.feed.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +private const val MIN_STACKED_ICON_COUNT = 1 +private const val MAX_STACKED_ICON_COUNT = 3 +private const val FIRST_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER = 1 +private const val SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER = 2 +private const val MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER = 2 +private const val FIRST_BACK_LAYER_ICON_ALPHA = 0.4f +private const val SECOND_BACK_LAYER_ICON_ALPHA = 0.2f + +@Composable +fun LayeringIcons( + tangemIconUM: TangemIconUM, + modifier: Modifier = Modifier, + count: Int = MIN_STACKED_ICON_COUNT, + layerHorizontalShift: Dp = TangemTheme.dimens2.x1, + iconSize: Dp = TangemTheme.dimens2.x10, +) { + require(count > 0) + + val stackTrailingWidth = layerHorizontalShift * SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER + + Box( + modifier = modifier.size( + width = iconSize + stackTrailingWidth, + height = iconSize, + ), + ) { + val baseIconModifier = Modifier + .align(Alignment.TopStart) + .size(iconSize) + + if (count >= MAX_STACKED_ICON_COUNT) { + TangemIcon( + tangemIconUM = tangemIconUM, + modifier = baseIconModifier + .offset(x = layerHorizontalShift * SECOND_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER) + .alpha(SECOND_BACK_LAYER_ICON_ALPHA), + ) + } + if (count >= MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER) { + TangemIcon( + tangemIconUM = tangemIconUM, + modifier = baseIconModifier + .offset(x = layerHorizontalShift * FIRST_BACK_LAYER_HORIZONTAL_OFFSET_MULTIPLIER) + .alpha(FIRST_BACK_LAYER_ICON_ALPHA), + ) + } + TangemIcon( + tangemIconUM = tangemIconUM, + modifier = baseIconModifier, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Composable +private fun LayeringIconsPreview() { + val previewCurrencyIcon = TangemIconUM.Currency( + currencyIconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = null, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ) + TangemThemePreview { + Column(horizontalAlignment = Alignment.End) { + LayeringIcons(count = MIN_STACKED_ICON_COUNT, tangemIconUM = previewCurrencyIcon) + LayeringIcons( + count = MAX_STACKED_ICON_COUNT, + tangemIconUM = previewCurrencyIcon, + ) + LayeringIcons( + count = MIN_STACKED_COUNT_FOR_FIRST_BACK_LAYER, + tangemIconUM = previewCurrencyIcon, + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt index 6d7118f812..73ab0434d4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt @@ -1,5 +1,6 @@ package com.tangem.features.feed.ui.search +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -8,9 +9,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -27,16 +27,20 @@ import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.ds.button.* -import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem +import com.tangem.features.feed.ui.search.components.SingleUserAssetItem import com.tangem.features.feed.ui.search.state.* +import kotlinx.collections.immutable.ImmutableList private const val PLACEHOLDER_COUNT = 10 private const val LOAD_MORE_THRESHOLD = 5 +private const val USER_ASSETS_LIMIT = 3 @Composable internal fun SearchContent( @@ -58,6 +62,14 @@ internal fun SearchContent( lazyListState.scrollToItem(0) } + var isUserAssetsExpanded by rememberSaveable { mutableStateOf(false) } + val shouldShowUserAssetsPortfolio = content is SearchContentUM.Results && content.userAssets.isNotEmpty() + LaunchedEffect(shouldShowUserAssetsPortfolio) { + if (!shouldShowUserAssetsPortfolio) { + isUserAssetsExpanded = false + } + } + LazyColumn( state = lazyListState, modifier = modifier @@ -80,6 +92,8 @@ internal fun SearchContent( ) is SearchContentUM.Results -> searchResultsItems( results = content, + isUserAssetsExpanded = isUserAssetsExpanded, + onUserAssetsExpandedChange = { isUserAssetsExpanded = it }, onResultMarketTokenClick = searchCallbacks.onResultMarketTokenClick, ) } @@ -147,18 +161,19 @@ private fun LazyListScope.searchHistoryItems( private fun LazyListScope.searchResultsItems( results: SearchContentUM.Results, + isUserAssetsExpanded: Boolean, + onUserAssetsExpandedChange: (Boolean) -> Unit, onResultMarketTokenClick: (MarketsListItemUM) -> Unit, ) { if (results.userAssets.isNotEmpty()) { item(key = "header_portfolio") { SectionHeader(title = stringResourceSafe(R.string.markets_search_portfolio_header)) } - items( - items = results.userAssets, - key = { it.id }, - ) { asset -> - UserAssetItem(asset) - } + userAssetsPortfolioItems( + assets = results.userAssets, + expanded = isUserAssetsExpanded, + onExpandedChange = onUserAssetsExpandedChange, + ) } when (val market = results.marketTokens) { @@ -175,6 +190,42 @@ private fun LazyListScope.searchResultsItems( } } +private fun LazyListScope.userAssetsPortfolioItems( + assets: ImmutableList, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, +) { + val shouldShowToggle = assets.size > USER_ASSETS_LIMIT + val visibleCount = if (shouldShowToggle && !expanded) USER_ASSETS_LIMIT else assets.size + + items( + count = visibleCount, + key = { index -> "user_asset_${assets[index].id}" }, + ) { index -> + Column( + modifier = Modifier.animateItem( + fadeInSpec = tween(durationMillis = 300), + fadeOutSpec = tween(durationMillis = 250), + ), + ) { + UserAssetItem(assets[index]) + SpacerH(TangemTheme.dimens2.x2) + } + } + if (shouldShowToggle) { + item(key = "user_assets_show_toggle") { + ShowAllUserAssetsButton( + modifier = Modifier.animateItem( + fadeInSpec = tween(durationMillis = 300), + fadeOutSpec = tween(durationMillis = 250), + ), + isExpanded = expanded, + onClick = { onExpandedChange(!expanded) }, + ) + } + } +} + private fun LazyListScope.marketSearchResultItems( market: MarketSearchResultUM.Content, hasUserAssetsSection: Boolean, @@ -234,7 +285,7 @@ private fun LazyListScope.marketSearchResultNotFoundItem() { ) { Text( text = stringResourceSafe(R.string.common_no_results), - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, color = TangemTheme.colors2.text.neutral.tertiary, ) } @@ -278,106 +329,34 @@ private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) { @Composable private fun UserAssetItem(asset: UserAssetItemUM) { when (asset) { - is UserAssetItemUM.Single -> SingleUserAssetItem(asset) - is UserAssetItemUM.Grouped -> GroupedUserAssetItem(asset) + is UserAssetItemUM.Single -> SingleUserAssetItem(item = asset) + is UserAssetItemUM.Grouped -> GroupedUserAssetItem(item = asset) } } @Composable -private fun SingleUserAssetItem(asset: UserAssetItemUM.Single) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = asset.onClick) - .padding(horizontal = 12.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), +private fun ShowAllUserAssetsButton(isExpanded: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, ) { - TangemIcon( - modifier = Modifier.size(40.dp), - tangemIconUM = asset.icon, + TangemButton( + buttonUM = TangemButtonUM( + text = if (isExpanded) { + resourceReference(R.string.feed_search_show_less_user_assets) + } else { + resourceReference(R.string.feed_search_show_all_user_assets) + }, + tangemIconUM = TangemIconUM.Icon( + iconRes = if (isExpanded) R.drawable.ic_chewron_up_20 else R.drawable.ic_chewron_down_20, + ), + iconPosition = TangemButtonIconPosition.End, + onClick = onClick, + type = TangemButtonType.Secondary, + size = TangemButtonSize.X7, + shape = TangemButtonShape.Rounded, + ), ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = asset.tokenName, - style = TangemTheme.typography2.bodySemibold16, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - ) - Text( - text = asset.tokenSymbol, - style = TangemTheme.typography2.captionRegular13, - color = TangemTheme.colors2.text.neutral.tertiary, - maxLines = 1, - ) - } - if (!asset.isBalanceHidden) { - Column(horizontalAlignment = Alignment.End) { - Text( - text = asset.fiatBalance, - style = TangemTheme.typography2.bodySemibold16, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - ) - Text( - text = asset.cryptoBalance, - style = TangemTheme.typography2.captionRegular13, - color = TangemTheme.colors2.text.neutral.tertiary, - maxLines = 1, - ) - } - } - } -} - -// TODO [REDACTED_JIRA] update ui item to Portfolio block item -@Composable -private fun GroupedUserAssetItem(asset: UserAssetItemUM.Grouped) { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = asset.onClick) - .padding(horizontal = 12.dp, vertical = 14.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TangemIcon( - modifier = Modifier.size(40.dp), - tangemIconUM = asset.icon, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = asset.tokenName, - style = TangemTheme.typography2.bodySemibold16, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - ) - Text( - text = "${asset.tokenSymbol} · ${asset.tokensCount}", - style = TangemTheme.typography2.captionRegular13, - color = TangemTheme.colors2.text.neutral.tertiary, - maxLines = 1, - ) - } - if (!asset.isBalanceHidden) { - Column(horizontalAlignment = Alignment.End) { - Text( - text = asset.totalFiatBalance, - style = TangemTheme.typography2.bodySemibold16, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - ) - Text( - text = asset.totalCryptoBalance, - style = TangemTheme.typography2.captionRegular13, - color = TangemTheme.colors2.text.neutral.tertiary, - maxLines = 1, - ) - } - } - } } } @@ -397,7 +376,7 @@ private fun ShowTokensUnder100kItem(onShowTokensClick: () -> Unit, modifier: Mod ) { Text( text = stringResourceSafe(R.string.markets_search_see_tokens_under_100k), - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, color = TangemTheme.colors2.text.neutral.secondary, ) TangemButton( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/BalanceColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/BalanceColumn.kt new file mode 100644 index 0000000000..552f07fcb3 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/BalanceColumn.kt @@ -0,0 +1,175 @@ +package com.tangem.features.feed.ui.search.components + +import androidx.compose.foundation.layout.* +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.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerW +import com.tangem.core.ui.components.flicker +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.utils.StringsSigns + +@Composable +internal fun BalanceColumn( + balanceState: BalanceDisplayState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + if (isBalanceHidden) { + HiddenBalance(modifier) + return + } + when (balanceState) { + is BalanceDisplayState.Loading -> LoadingBalanceColumn(modifier) + is BalanceDisplayState.Flickering -> FlickeringBalanceColumn(balanceState, modifier) + is BalanceDisplayState.Stale -> StaleBalanceColumn(balanceState, modifier) + is BalanceDisplayState.Unreachable -> UnreachableBalanceColumn(modifier) + is BalanceDisplayState.Loaded -> LoadedBalanceColumn(balanceState, modifier) + } +} + +@Composable +private fun BalanceColumnLayout(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + content = content, + ) +} + +@Composable +private fun LoadingBalanceColumn(modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + RectangleShimmer(modifier = Modifier.size(width = 108.dp, height = 20.dp), radius = 20.dp) + RectangleShimmer(modifier = Modifier.size(width = 64.dp, height = 16.dp), radius = 20.dp) + } +} + +@Composable +private fun FlickeringBalanceColumn(state: BalanceDisplayState.Flickering, modifier: Modifier = Modifier) { + val flickerModifier = Modifier.flicker(isFlickering = true) + BalanceColumnLayout(modifier) { + Text( + modifier = flickerModifier, + text = state.fiatBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = flickerModifier, + text = state.cryptoBalance.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun StaleBalanceColumn(state: BalanceDisplayState.Stale, modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = R.drawable.ic_error_sync_24), + contentDescription = null, + tint = TangemTheme.colors2.markers.iconGray, + ) + SpacerW(TangemTheme.dimens2.x0_5) + Text( + text = state.fiatBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = state.cryptoBalance.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun UnreachableBalanceColumn(modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + Text( + text = StringsSigns.DASH_SIGN, + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResourceSafe(R.string.common_unreachable), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.status.attention, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + SpacerW(TangemTheme.dimens2.x0_5) + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = R.drawable.ic_alert_triange_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.status.attention, + ) + } + } +} + +@Composable +private fun LoadedBalanceColumn(state: BalanceDisplayState.Loaded, modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + Text( + text = state.fiatBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = state.cryptoBalance.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + } +} + +@Composable +private fun HiddenBalance(modifier: Modifier = Modifier) { + BalanceColumnLayout(modifier) { + Text( + text = StringsSigns.THREE_STARS, + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = StringsSigns.THREE_STARS, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt new file mode 100644 index 0000000000..11504f43d5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt @@ -0,0 +1,84 @@ +package com.tangem.features.feed.ui.search.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.ds.button.* +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.pluralStringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.impl.R +import com.tangem.features.feed.ui.components.LayeringIcons +import com.tangem.features.feed.ui.search.state.UserAssetItemUM + +@Composable +internal fun GroupedUserAssetItem(item: UserAssetItemUM.Grouped, modifier: Modifier = Modifier) { + Box( + modifier = modifier.background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + TangemRowContainer( + modifier = Modifier.clickable(onClick = item.onClick), + content = { + LayeringIcons( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x1), + tangemIconUM = item.icon, + count = item.tokensCount, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = item.tokenName, + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = pluralStringResourceSafe(R.plurals.common_tokens_count, item.tokensCount, item.tokensCount), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + BalanceColumn( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP), + balanceState = item.balanceState, + isBalanceHidden = item.isBalanceHidden, + ) + + TangemButton( + modifier = Modifier + .padding(start = TangemTheme.dimens2.x2) + .layoutId(TangemRowLayoutId.TAIL), + buttonUM = TangemButtonUM( + type = TangemButtonType.Secondary, + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_chevron_24, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + shape = TangemButtonShape.Rounded, + size = TangemButtonSize.X10, + onClick = item.onClick, + ), + ) + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt new file mode 100644 index 0000000000..cf255f66a5 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt @@ -0,0 +1,117 @@ +package com.tangem.features.feed.ui.search.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.marketprice.PriceChangeInPercent +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.features.feed.ui.search.state.UserAssetItemUM + +@Composable +fun SingleUserAssetItem(item: UserAssetItemUM.Single, modifier: Modifier = Modifier) { + Box( + modifier = modifier.background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + TangemRowContainer( + modifier = Modifier.clickable(onClick = item.onClick), + content = { + TangemIcon( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .size(40.dp) + .padding(end = TangemTheme.dimens2.x1), + tangemIconUM = item.icon, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = item.tokenName, + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + + PriceBlock( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + priceChangeState = item.priceChangeState, + fiatRate = item.fiatRate, + balanceState = item.balanceState, + ) + + BalanceColumn( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP), + balanceState = item.balanceState, + isBalanceHidden = item.isBalanceHidden, + ) + }, + ) + } +} + +@Composable +private fun PriceBlock( + priceChangeState: PriceChangeState, + fiatRate: String?, + balanceState: BalanceDisplayState, + modifier: Modifier = Modifier, +) { + val isDisabled = remember(balanceState) { + balanceState is BalanceDisplayState.Unreachable + } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + if (fiatRate != null) { + Text( + text = fiatRate, + style = TangemTheme.typography2.captionMedium12, + color = if (isDisabled) { + TangemTheme.colors2.text.status.disabled + } else { + TangemTheme.colors2.text.neutral.secondary + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + AnimatedContent( + targetState = priceChangeState, + contentKey = { it::class }, + ) { animatedState -> + when (animatedState) { + is PriceChangeState.Content -> { + PriceChangeInPercent( + valueInPercent = animatedState.valueInPercent, + type = animatedState.type, + textStyle = TangemTheme.typography2.captionMedium12, + isDisabled = isDisabled, + ) + } + PriceChangeState.Unknown -> Unit + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt index e5cd68f79c..58334e7bce 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference @@ -217,8 +218,14 @@ internal object SearchContentPreviewFixtures { tokenName = name, tokenSymbol = symbol, fiatRate = "$98,765.43", - cryptoBalance = "1.234 $symbol", - fiatBalance = "$121,876.50", + priceChangeState = PriceChangeState.Content( + type = PriceChangeType.UP, + valueInPercent = "+2.34%", + ), + balanceState = BalanceDisplayState.Loaded( + cryptoBalance = stringReference("1.234 $symbol"), + fiatBalance = stringReference("$121,876.50"), + ), isBalanceHidden = false, onClick = {}, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt new file mode 100644 index 0000000000..f2f8302d45 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt @@ -0,0 +1,238 @@ +package com.tangem.features.feed.ui.search.preview + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem +import com.tangem.features.feed.ui.search.components.SingleUserAssetItem +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.features.feed.ui.search.state.UserAssetItemUM +import kotlinx.collections.immutable.persistentListOf + +/** Labeled UI state for [SingleUserAssetItem] previews (dropdown label in Studio). */ +internal data class SingleUserAssetItemPreviewScenario( + val title: String, + val item: UserAssetItemUM.Single, +) + +/** Labeled UI state for [GroupedUserAssetItem] previews. */ +internal data class GroupedUserAssetItemPreviewScenario( + val title: String, + val item: UserAssetItemUM.Grouped, +) + +@Suppress("StringLiteralDuplication") +internal object UserAssetItemPreviewFixtures { + + private val sampleIcon = TangemIconUM.Currency( + CurrencyIconState.CoinIcon( + url = null, + fallbackResId = com.tangem.core.ui.R.drawable.ic_ethereumpow_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ) + + private val cryptoRef = stringReference("1.234 ETH") + private val fiatRef = stringReference("$121,876.50") + + private val samplePriceChange = PriceChangeState.Content( + valueInPercent = "+2.34%", + type = PriceChangeType.UP, + ) + + private fun balanceLoaded() = BalanceDisplayState.Loaded( + cryptoBalance = cryptoRef, + fiatBalance = fiatRef, + ) + + private fun balanceFlickering() = BalanceDisplayState.Flickering( + cryptoBalance = cryptoRef, + fiatBalance = fiatRef, + ) + + private fun balanceStale() = BalanceDisplayState.Stale( + cryptoBalance = cryptoRef, + fiatBalance = fiatRef, + ) + + private fun balanceLoading() = BalanceDisplayState.Loading + + private fun balanceUnreachable() = BalanceDisplayState.Unreachable + + fun allSingleScenarios(): List = listOf( + SingleUserAssetItemPreviewScenario( + title = "Hidden (balanceState ignored)", + item = single( + balanceState = balanceLoaded(), + isBalanceHidden = true, + ), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Loaded", + item = single(balanceState = balanceLoaded(), isBalanceHidden = false), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Flickering", + item = single(balanceState = balanceFlickering(), isBalanceHidden = false), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Stale", + item = single(balanceState = balanceStale(), isBalanceHidden = false), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Loading", + item = single(balanceState = balanceLoading(), isBalanceHidden = false), + ), + SingleUserAssetItemPreviewScenario( + title = "Balance – Unreachable", + item = single(balanceState = balanceUnreachable(), isBalanceHidden = false), + ), + ) + + fun allGroupedScenarios(): List = listOf( + GroupedUserAssetItemPreviewScenario( + title = "Hidden (balanceState ignored)", + item = grouped( + balanceState = balanceLoaded(), + isBalanceHidden = true, + ), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Loaded", + item = grouped(balanceState = balanceLoaded(), isBalanceHidden = false), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Flickering", + item = grouped(balanceState = balanceFlickering(), isBalanceHidden = false), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Stale", + item = grouped(balanceState = balanceStale(), isBalanceHidden = false), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Loading", + item = grouped(balanceState = balanceLoading(), isBalanceHidden = false), + ), + GroupedUserAssetItemPreviewScenario( + title = "Balance – Unreachable", + item = grouped(balanceState = balanceUnreachable(), isBalanceHidden = false), + ), + ) + + private fun single(balanceState: BalanceDisplayState, isBalanceHidden: Boolean): UserAssetItemUM.Single = + UserAssetItemUM.Single( + id = "single_preview", + icon = sampleIcon, + tokenName = "Ethereum", + tokenSymbol = "ETH", + fiatRate = "$98,765.43", + priceChangeState = samplePriceChange, + balanceState = balanceState, + isBalanceHidden = isBalanceHidden, + onClick = {}, + ) + + private fun grouped(balanceState: BalanceDisplayState, isBalanceHidden: Boolean): UserAssetItemUM.Grouped = + UserAssetItemUM.Grouped( + id = "grouped_preview", + icon = sampleIcon, + tokenName = "Ethereum", + tokenSymbol = "ETH", + tokensCount = 3, + balanceState = balanceState, + isBalanceHidden = isBalanceHidden, + children = persistentListOf(), + onClick = {}, + ) +} + +internal class SingleUserAssetItemPreviewParameterProvider : + PreviewParameterProvider { + override val values: Sequence + get() = UserAssetItemPreviewFixtures.allSingleScenarios().asSequence() +} + +internal class GroupedUserAssetItemPreviewParameterProvider : + PreviewParameterProvider { + override val values: Sequence + get() = UserAssetItemPreviewFixtures.allGroupedScenarios().asSequence() +} + +@Composable +private fun SingleUserAssetItemPreviewHost( + scenario: SingleUserAssetItemPreviewScenario, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + SingleUserAssetItem(item = scenario.item) + } +} + +@Composable +private fun GroupedUserAssetItemPreviewHost( + scenario: GroupedUserAssetItemPreviewScenario, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level1) + .padding(8.dp), + ) { + GroupedUserAssetItem(item = scenario.item) + } +} + +@Composable +@Preview(name = "Single – all balance states (parameter)", showBackground = true, widthDp = 360) +@Preview( + name = "Single – all balance states (night)", + showBackground = true, + widthDp = 360, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun SingleUserAssetItemPreview_AllBalanceStates( + @PreviewParameter(SingleUserAssetItemPreviewParameterProvider::class) scenario: SingleUserAssetItemPreviewScenario, +) { + TangemThemePreviewRedesign { + SingleUserAssetItemPreviewHost(scenario = scenario) + } +} + +@Composable +@Preview(name = "Grouped – all balance states (parameter)", showBackground = true, widthDp = 360) +@Preview( + name = "Grouped – all balance states (night)", + showBackground = true, + widthDp = 360, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private fun GroupedUserAssetItemPreview_AllBalanceStates( + @PreviewParameter(GroupedUserAssetItemPreviewParameterProvider::class) + scenario: GroupedUserAssetItemPreviewScenario, +) { + TangemThemePreviewRedesign { + GroupedUserAssetItemPreviewHost(scenario = scenario) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt index ede3decb1b..31277562c6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt @@ -4,8 +4,11 @@ import androidx.compose.runtime.Immutable import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.ui.components.fields.entity.SearchBarUM +import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.currency.CryptoCurrencyStatus import kotlinx.collections.immutable.ImmutableList data class SearchUM( @@ -45,6 +48,28 @@ sealed interface MarketSearchResultUM { data class TextHintItemUM(val text: String) +@Immutable +sealed interface BalanceDisplayState { + + data class Loaded( + val cryptoBalance: TextReference, + val fiatBalance: TextReference, + ) : BalanceDisplayState + + data class Flickering( + val cryptoBalance: TextReference, + val fiatBalance: TextReference, + ) : BalanceDisplayState + + data class Stale( + val cryptoBalance: TextReference, + val fiatBalance: TextReference, + ) : BalanceDisplayState + + data object Loading : BalanceDisplayState + data object Unreachable : BalanceDisplayState +} + @Immutable sealed interface UserAssetItemUM { val id: String @@ -59,8 +84,8 @@ sealed interface UserAssetItemUM { override val tokenName: String, override val tokenSymbol: String, val fiatRate: String?, - val cryptoBalance: String, - val fiatBalance: String, + val priceChangeState: PriceChangeState, + val balanceState: BalanceDisplayState, val isBalanceHidden: Boolean, override val onClick: () -> Unit, ) : UserAssetItemUM @@ -71,8 +96,7 @@ sealed interface UserAssetItemUM { override val tokenName: String, override val tokenSymbol: String, val tokensCount: Int, - val totalCryptoBalance: String, - val totalFiatBalance: String, + val balanceState: BalanceDisplayState, val isBalanceHidden: Boolean, val children: ImmutableList, override val onClick: () -> Unit, @@ -83,7 +107,6 @@ sealed interface UserAssetItemUM { val accountName: AccountNameUM, val accountIcon: CryptoPortfolioIcon.Icon, val accountColor: CryptoPortfolioIcon.Color, - val cryptoBalance: String, - val fiatBalance: String, + val currencyStatus: CryptoCurrencyStatus, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt index 2bbfa496e2..5963f2ea34 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/EntryContentAnimationTransitions.kt @@ -27,11 +27,7 @@ internal fun contentFeedEntryStackAnimation(): StackAnimation< ComposableModularBottomSheetContentComponent, > = stackAnimation { to, from, _ -> - val isSearchToTokenList = - (to.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true - val isFromSearchTokenList = - (from.configuration as? FeedEntryChildFactory.Child.TokenList)?.params?.shouldAlwaysShowSearchBar == true - if (isSearchToTokenList || isFromSearchTokenList) { + if (to.configuration.usesFadeStackTransition() || from.configuration.usesFadeStackTransition()) { fade() } else { slide() diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlockLegacy.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlockLegacy.kt index 820fa36229..79c058b390 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlockLegacy.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlockLegacy.kt @@ -106,6 +106,7 @@ private fun LeftSide( modifier = Modifier.alignByBaseline(), valueInPercent = percentText, type = type, + textStyle = TangemTheme.typography.body2, ) Text( modifier = Modifier.alignByBaseline(), From c72ffd2e81550464414ca9afd31868c33fdd97ca Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Apr 2026 16:55:50 +0400 Subject: [PATCH 018/206] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + common/ui-markets/build.gradle.kts | 16 ++++ .../ui/markets/action/CryptoCurrencyData.kt | 11 +++ .../ui/markets/action}/QuickActionUM.kt | 6 +- .../common/ui/markets/action/QuickActions.kt | 9 ++ .../markets/action/QuickActionsConverter.kt | 61 +++++++++++++ .../action}/TokenActionsBSContentUM.kt | 6 +- .../ui/markets/action}/TokenActionsHandler.kt | 28 +++--- features/common-features/api/.gitignore | 1 + features/common-features/api/build.gradle.kts | 27 ++++++ .../AddToPortfolioComponent.kt | 2 +- .../addtoportfolio}/AddToPortfolioManager.kt | 2 +- .../AddToPortfolioPreselectedDataComponent.kt | 2 +- .../api/addtoportfolio}/AvailableToAddData.kt | 2 +- features/common-features/impl/.gitignore | 1 + .../common-features/impl/build.gradle.kts | 81 +++++++++++++++++ .../AddToPortfolioBottomSheet.kt | 6 +- .../impl/addtoportfolio}/AddTokenComponent.kt | 10 +- .../addtoportfolio}/ChooseNetworkComponent.kt | 8 +- .../DefaultAddToPortfolioComponent.kt | 8 +- ...tAddToPortfolioPreselectedDataComponent.kt | 8 +- .../addtoportfolio}/TokenActionsComponent.kt | 12 +-- .../analytics/EarnAnalyticsEvent.kt | 35 +++++++ .../analytics/PortfolioAnalyticsEvent.kt | 91 +++++++++++++++++++ .../converter/AvailableToAddDataConverter.kt | 8 +- .../converter}/BlockchainRowUMConverter.kt | 14 +-- .../di/AddToPortfolioComponentModule.kt | 28 ++++++ .../di/AddToPortfolioModelModule.kt | 8 +- .../model/AddToPortfolioModel.kt | 26 +++--- .../AddToPortfolioPreselectedDataModel.kt | 16 ++-- .../model/AddToPortfolioRoutes.kt | 4 +- .../addtoportfolio}/model/AddTokenModel.kt | 12 +-- .../model/AddTokenUiBuilder.kt | 10 +- .../model/CheckCurrencyUnsupportedDelegate.kt | 4 +- .../model/ChooseNetworkModel.kt | 8 +- .../model/TokenActionsModel.kt | 10 +- .../model/TokenActionsUiBuilder.kt | 16 ++-- .../ui/ChooseNetworkContent.kt | 6 +- .../ui/DefaultAddToPortfolioManager.kt | 6 +- .../addtoportfolio}/ui/TokenActionsContent.kt | 12 +-- .../ui/state/ChooseNetworkUM.kt | 2 +- .../addtoportfolio/ui/state/TokenActionsUM.kt | 10 ++ features/feed/impl/build.gradle.kts | 1 + .../feed/components/FeedEntryChildFactory.kt | 2 +- .../components/earn/DefaultEarnComponent.kt | 2 +- .../components/feed/DefaultFeedComponent.kt | 4 +- .../components/feed/FeedBottomSheetRoute.kt | 2 +- .../impl/di/AddToPortfolioComponentModule.kt | 28 ------ .../add/impl/ui/state/TokenActionsUM.kt | 10 -- .../impl/DefaultMarketsPortfolioComponent.kt | 2 +- .../impl/analytics/PortfolioAnalyticsEvent.kt | 76 +--------------- .../portfolio/impl/loader/PortfolioData.kt | 33 ------- .../impl/model/MarketsPortfolioDelegate.kt | 5 +- .../impl/model/MarketsPortfolioModel.kt | 7 +- .../impl/model/PortfolioTokenUMConverter.kt | 73 ++------------- .../impl/ui/PortfolioQuickActions.kt | 2 +- .../preview/PreviewMyPortfolioUMProvider.kt | 4 +- .../impl/ui/state/PortfolioTokenUM.kt | 8 +- .../features/feed/model/earn/EarnModel.kt | 2 +- .../earn/analytics/EarnAnalyticsEvent.kt | 24 ----- .../feed/model/feed/FeedComponentModel.kt | 2 +- features/onramp/impl/build.gradle.kts | 3 + .../swap/DefaultSwapSelectTokensComponent.kt | 2 +- .../AvailableSwapPairsComponent.kt | 4 +- .../DefaultAvailableSwapPairsComponent.kt | 4 +- .../model/AvailableSwapPairsModel.kt | 4 +- features/swap/impl/build.gradle.kts | 3 + .../impl/DefaultChooseTokenComponent.kt | 4 +- .../impl/model/ChooseTokenModel.kt | 2 +- .../impl/model/MarketBlockDelegate.kt | 4 +- settings.gradle.kts | 3 + 71 files changed, 556 insertions(+), 399 deletions(-) create mode 100644 common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action}/QuickActionUM.kt (91%) create mode 100644 common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt create mode 100644 common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action}/TokenActionsBSContentUM.kt (91%) rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action}/TokenActionsHandler.kt (85%) create mode 100644 features/common-features/api/.gitignore create mode 100644 features/common-features/api/build.gradle.kts rename features/{feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add => common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio}/AddToPortfolioComponent.kt (90%) rename features/{feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add => common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio}/AddToPortfolioManager.kt (94%) rename features/{feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add => common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio}/AddToPortfolioPreselectedDataComponent.kt (94%) rename features/{feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add => common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio}/AvailableToAddData.kt (96%) create mode 100644 features/common-features/impl/.gitignore create mode 100644 features/common-features/impl/build.gradle.kts rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/AddToPortfolioBottomSheet.kt (95%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/AddTokenComponent.kt (79%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/ChooseNetworkComponent.kt (79%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/DefaultAddToPortfolioComponent.kt (91%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/DefaultAddToPortfolioPreselectedDataComponent.kt (89%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/TokenActionsComponent.kt (83%) create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/EarnAnalyticsEvent.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/converter/AvailableToAddDataConverter.kt (92%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter}/BlockchainRowUMConverter.kt (79%) create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/di/AddToPortfolioModelModule.kt (66%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/model/AddToPortfolioModel.kt (94%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/model/AddToPortfolioPreselectedDataModel.kt (95%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/model/AddToPortfolioRoutes.kt (78%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/model/AddTokenModel.kt (92%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/model/AddTokenUiBuilder.kt (91%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/model/CheckCurrencyUnsupportedDelegate.kt (96%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/model/ChooseNetworkModel.kt (85%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/model/TokenActionsModel.kt (86%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/model/TokenActionsUiBuilder.kt (65%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/ui/ChooseNetworkContent.kt (94%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/ui/DefaultAddToPortfolioManager.kt (90%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/ui/TokenActionsContent.kt (94%) rename features/{feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio}/ui/state/ChooseNetworkUM.kt (73%) create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt delete mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt delete mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt delete mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5def3358f3..6f398fb14f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -274,6 +274,8 @@ dependencies { implementation(projects.features.nft.impl) implementation(projects.features.walletconnect.api) implementation(projects.features.walletconnect.impl) + implementation(projects.features.commonFeatures.api) + implementation(projects.features.commonFeatures.impl) implementation(projects.features.usedesk.api) implementation(projects.features.usedesk.impl) implementation(projects.features.hotWallet.api) diff --git a/common/ui-markets/build.gradle.kts b/common/ui-markets/build.gradle.kts index b83e4298e2..7eb6c53bd1 100644 --- a/common/ui-markets/build.gradle.kts +++ b/common/ui-markets/build.gradle.kts @@ -1,6 +1,8 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) id("configuration") } @@ -12,13 +14,23 @@ dependencies { /** Project - Core */ implementation(projects.core.ui) implementation(projects.core.utils) + implementation(projects.core.navigation) + implementation(projects.core.analytics) /** Project - Common */ implementation(projects.common.uiCharts) implementation(projects.common.ui) + implementation(projects.common.routing) /** Project - Domain */ implementation(projects.domain.models) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.staking) + implementation(projects.domain.staking.models) + implementation(projects.domain.onramp.models) + implementation(projects.domain.offramp) + implementation(projects.domain.demo) implementation(deps.lifecycle.compose) implementation(deps.compose.foundation) @@ -26,4 +38,8 @@ dependencies { implementation(deps.compose.ui.tooling) implementation(deps.compose.ui.utils) implementation(deps.kotlin.immutable.collections) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) } \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt new file mode 100644 index 0000000000..34ecb8e2f3 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt @@ -0,0 +1,11 @@ +package com.tangem.common.ui.markets.action + +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.tokens.model.TokenActionsState + +data class CryptoCurrencyData( + val userWallet: UserWallet, + val status: CryptoCurrencyStatus, + val actions: List, +) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt similarity index 91% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt index c98c012df0..c2565c295b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/QuickActionUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt @@ -1,14 +1,14 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state +package com.tangem.common.ui.markets.action import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.R import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.features.feed.impl.R @Immutable -internal sealed class QuickActionUM( +sealed class QuickActionUM( val title: TextReference, val description: TextReference, @DrawableRes val icon: Int, diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt new file mode 100644 index 0000000000..27d4bfac71 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActions.kt @@ -0,0 +1,9 @@ +package com.tangem.common.ui.markets.action + +import kotlinx.collections.immutable.ImmutableList + +data class QuickActions( + val actions: ImmutableList, + val onQuickActionClick: (QuickActionUM) -> Unit, + val onQuickActionLongClick: (QuickActionUM) -> Unit, +) \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt new file mode 100644 index 0000000000..f0112cab38 --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt @@ -0,0 +1,61 @@ +package com.tangem.common.ui.markets.action + +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.tokens.model.TokenActionsState +import kotlinx.collections.immutable.toImmutableList + +object QuickActionsConverter { + + fun quickActions(cryptoData: CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): QuickActions { + return QuickActions( + actions = toQuickActions(cryptoData.actions), + onQuickActionClick = { quickActionUM -> + when (quickActionUM) { + QuickActionUM.Buy -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Buy, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.Exchange -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Exchange, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Receive -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Receive, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.Stake -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Stake, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.YieldMode -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.YieldMode, + cryptoCurrencyData = cryptoData, + ) + } + }, + onQuickActionLongClick = { actionUM -> + if (actionUM == QuickActionUM.Receive) { + tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.CopyAddress, + cryptoCurrencyData = cryptoData, + ) + } + }, + ) + } + + fun toQuickActions(actions: List) = buildList { + actions.forEach { action -> + if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { + when (action) { + is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy + is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.shouldShowBadge) + is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive + is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake + is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy) + else -> null + }?.let(::add) + } + } + }.toImmutableList() +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt similarity index 91% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt index f35729eaa8..f33fee7c3d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/state/TokenActionsBSContentUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsBSContentUM.kt @@ -1,14 +1,14 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.ui.state +package com.tangem.common.ui.markets.action import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.R import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.features.feed.impl.R import kotlinx.collections.immutable.ImmutableList -internal data class TokenActionsBSContentUM( +data class TokenActionsBSContentUM( val title: String, val actions: ImmutableList, val onActionClick: (Action) -> Unit, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt similarity index 85% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt index 3f6071b417..90f8b3281b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/TokenActionsHandler.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt @@ -1,7 +1,8 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.model +package com.tangem.common.ui.markets.action import com.tangem.common.routing.AppRoute import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels +import com.tangem.common.ui.markets.R import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent @@ -18,9 +19,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.offramp.GetOfframpUrlUseCase import com.tangem.domain.onramp.model.OnrampSource import com.tangem.domain.tokens.model.TokenActionsState -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM -import com.tangem.features.feed.impl.R import com.tangem.utils.Provider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -28,7 +26,7 @@ import dagger.assisted.AssistedInject import kotlinx.collections.immutable.toImmutableList @Suppress("LongParameterList") -internal class TokenActionsHandler @AssistedInject constructor( +class TokenActionsHandler @AssistedInject constructor( private val router: Router, private val clipboardManager: ClipboardManager, private val uiMessageSender: UiMessageSender, @@ -45,7 +43,7 @@ internal class TokenActionsHandler @AssistedInject constructor( add(TokenActionsBSContentUM.Action.Sell) } - fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + fun handle(action: TokenActionsBSContentUM.Action, cryptoCurrencyData: CryptoCurrencyData) { onHandleQuickAction( HandledQuickAction( action = action, @@ -79,13 +77,13 @@ internal class TokenActionsHandler @AssistedInject constructor( } private fun showDemoModeWarning() { - val message = DialogMessage( + val message = DialogMessage.Companion( message = resourceReference(R.string.alert_demo_feature_disabled), ) messageSender.send(message) } - private fun onCopyAddress(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onCopyAddress(cryptoCurrencyData: CryptoCurrencyData) { val cryptoCurrencyStatus = cryptoCurrencyData.status val networkAddress = cryptoCurrencyStatus.value.networkAddress ?: return val addresses = networkAddress.availableAddresses @@ -97,7 +95,7 @@ internal class TokenActionsHandler @AssistedInject constructor( uiMessageSender.send(SnackbarMessage(resourceReference(R.string.wallet_notification_address_copied))) } - private fun onBuyClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onBuyClick(cryptoCurrencyData: CryptoCurrencyData) { router.push( AppRoute.Onramp( userWalletId = cryptoCurrencyData.userWallet.walletId, @@ -107,7 +105,7 @@ internal class TokenActionsHandler @AssistedInject constructor( ) } - private fun onSellClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onSellClick(cryptoCurrencyData: CryptoCurrencyData) { getOfframpUrlUseCase( cryptoCurrencyStatus = cryptoCurrencyData.status, appCurrencyCode = currentAppCurrency().code, @@ -117,7 +115,7 @@ internal class TokenActionsHandler @AssistedInject constructor( } } - private fun onExchangeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onExchangeClick(cryptoCurrencyData: CryptoCurrencyData) { router.push( AppRoute.Swap( currencyFrom = cryptoCurrencyData.status.currency, @@ -128,7 +126,7 @@ internal class TokenActionsHandler @AssistedInject constructor( ) } - private fun onSendClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onSendClick(cryptoCurrencyData: CryptoCurrencyData) { val route = AppRoute.SendEntryPoint( userWalletId = cryptoCurrencyData.userWallet.walletId, currency = cryptoCurrencyData.status.currency, @@ -136,7 +134,7 @@ internal class TokenActionsHandler @AssistedInject constructor( router.push(route) } - private fun onStakeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onStakeClick(cryptoCurrencyData: CryptoCurrencyData) { val option = cryptoCurrencyData.actions.firstOrNull { it is TokenActionsState.ActionState.Stake } ?.let { it as TokenActionsState.ActionState.Stake } ?.option ?: return @@ -150,7 +148,7 @@ internal class TokenActionsHandler @AssistedInject constructor( ) } - private fun onYieldModeClick(cryptoCurrencyData: PortfolioData.CryptoCurrencyData) { + private fun onYieldModeClick(cryptoCurrencyData: CryptoCurrencyData) { val yieldSupplyApy = cryptoCurrencyData.actions.filterIsInstance() .firstOrNull()?.apy ?: return @@ -173,6 +171,6 @@ internal class TokenActionsHandler @AssistedInject constructor( data class HandledQuickAction( val action: TokenActionsBSContentUM.Action, - val cryptoCurrencyData: PortfolioData.CryptoCurrencyData, + val cryptoCurrencyData: CryptoCurrencyData, ) } \ No newline at end of file diff --git a/features/common-features/api/.gitignore b/features/common-features/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/common-features/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/common-features/api/build.gradle.kts b/features/common-features/api/build.gradle.kts new file mode 100644 index 0000000000..f46795f50f --- /dev/null +++ b/features/common-features/api/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +android { + namespace = "com.tangem.features.commonfeatures.api" +} + +dependencies { + /** Api */ // todo swap delete after move portfolio selector + implementation(projects.features.account.api) + + /* Project - Domain */ + implementation(projects.domain.models) + implementation(projects.domain.markets) + + /* Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /* Compose */ + implementation(deps.compose.runtime) + implementation(deps.kotlin.immutable.collections) +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt similarity index 90% rename from features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt index 5a1dabaa43..7e3d52793e 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioComponent.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add +package com.tangem.features.commonfeatures.api.addtoportfolio import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt similarity index 94% rename from features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt index 50b323e258..594e0e18ec 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioManager.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add +package com.tangem.features.commonfeatures.api.addtoportfolio import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioPreselectedDataComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.kt similarity index 94% rename from features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioPreselectedDataComponent.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.kt index c7feac3a8c..59b4f03918 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AddToPortfolioPreselectedDataComponent.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add +package com.tangem.features.commonfeatures.api.addtoportfolio import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AvailableToAddData.kt similarity index 96% rename from features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AvailableToAddData.kt index e97bb37f05..d5e89d19f7 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/AvailableToAddData.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AvailableToAddData.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add +package com.tangem.features.commonfeatures.api.addtoportfolio import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.account.AccountId diff --git a/features/common-features/impl/.gitignore b/features/common-features/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/common-features/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/common-features/impl/build.gradle.kts b/features/common-features/impl/build.gradle.kts new file mode 100644 index 0000000000..01aef9c2e7 --- /dev/null +++ b/features/common-features/impl/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.commonfeatures.impl" +} + +dependencies { + /** Api */ + implementation(projects.features.commonFeatures.api) + // todo swap delete after move portfolio selector + implementation(projects.features.account.api) + implementation(projects.features.tokenRecieve.api) + + /** Core modules */ + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + implementation(projects.core.utils) + implementation(projects.core.ui) + implementation(projects.core.error) + implementation(projects.core.res) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.datasource) + + /** Domain */ + implementation(projects.domain.models) + implementation(projects.domain.account) + implementation(projects.domain.account.status) + implementation(projects.domain.core) + implementation(projects.domain.appCurrency) + implementation(projects.domain.appCurrency.models) + implementation(projects.domain.markets) + implementation(projects.domain.transaction) + implementation(projects.domain.tokens) + implementation(projects.domain.tokens.models) + implementation(projects.domain.manageTokens) + implementation(projects.domain.manageTokens.models) + implementation(projects.domain.balanceHiding) + implementation(projects.domain.balanceHiding.models) + implementation(projects.domain.wallets) + implementation(projects.domain.wallets.models) + + /** Tangem libraries */ + implementation(tangemDeps.card.core) + + /** Common */ + implementation(projects.common.ui) + implementation(projects.common.uiMarkets) + implementation(projects.common.routing) + + /** AndroidX libraries */ + implementation(deps.androidx.core.ktx) + implementation(deps.lifecycle.runtime.ktx) + + /** Compose libraries */ + implementation(deps.compose.material3) + implementation(deps.compose.animation) + implementation(deps.compose.foundation) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.compose.coil) + implementation(deps.decompose.ext.compose) + implementation(deps.androidx.activity.compose) + + /** Other libraries */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + implementation(deps.kotlin.serialization) + implementation(deps.firebase.crashlytics) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddToPortfolioBottomSheet.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt similarity index 95% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddToPortfolioBottomSheet.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt index 8c6aba3001..c649a267f2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddToPortfolioBottomSheet.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Column @@ -9,6 +9,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.tangem.features.commonfeatures.impl.R import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -20,8 +21,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes @Composable internal fun AddToPortfolioBottomSheet( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt similarity index 79% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt index b762aa9727..9175781e37 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/AddTokenComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -9,10 +9,10 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenModel -import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ChooseNetworkComponent.kt similarity index 79% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ChooseNetworkComponent.kt index 71c5a6dc55..95dab8e47a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ChooseNetworkComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ChooseNetworkComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -9,9 +9,9 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.ChooseNetworkModel -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.ChooseNetworkContent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.ChooseNetworkContent +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt similarity index 91% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt index 8f7dfb79a1..b072275b3b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable import com.arkivanov.decompose.ComponentContext @@ -12,9 +12,9 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioModel -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioPreselectedDataComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt similarity index 89% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioPreselectedDataComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt index 95efde9298..f2b8949242 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/DefaultAddToPortfolioPreselectedDataComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -10,9 +10,9 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioPreselectedDataModel -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioPreselectedDataModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt similarity index 83% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt index 9498c4041f..4f2af5aa01 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/TokenActionsComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl +package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -8,6 +8,7 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.ui.markets.action.CryptoCurrencyData import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.factory.ComponentFactory @@ -15,10 +16,9 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.TokenReceiveConfig -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.TokenActionsModel -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.TokenActionsContent -import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContent import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -64,7 +64,7 @@ internal class TokenActionsComponent @AssistedInject constructor( data class Params( val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, - val data: Flow, + val data: Flow, val callbacks: Callbacks, ) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/EarnAnalyticsEvent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/EarnAnalyticsEvent.kt new file mode 100644 index 0000000000..5980f4c26c --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/EarnAnalyticsEvent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam + +// todo swap unify with PortfolioAnalyticsEvent, AddToPortfolioFlow +internal sealed class EarnAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Earn", event = event, params = params) { + + data class AddTokenScreenOpened( + private val tokenSymbol: String, + private val blockchain: String, + private val source: String, + ) : EarnAnalyticsEvent( + event = "Add Token Screen Opened", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to tokenSymbol, + AnalyticsParam.BLOCKCHAIN to blockchain, + AnalyticsParam.SOURCE to source, + ), + ) + + data class TokenAdded( + private val tokenSymbol: String, + private val blockchain: String, + ) : EarnAnalyticsEvent( + event = "Token Added", + params = mapOf( + AnalyticsParam.TOKEN_PARAM to tokenSymbol, + AnalyticsParam.BLOCKCHAIN to blockchain, + ), + ) +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt new file mode 100644 index 0000000000..6f72186a1b --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt @@ -0,0 +1,91 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics + +import com.tangem.common.ui.markets.action.TokenActionsBSContentUM +import com.tangem.core.analytics.models.AnalyticsEvent + +// todo swap unify with EarnAnalyticsEvent, AddToPortfolioFlow +internal class PortfolioAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { + + data class EventBuilder( + val tokenSymbol: String, + val source: String?, + ) { + + fun popupToChooseAccount() = PortfolioAnalyticsEvent( + event = "Choose Account Opened", + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun popupToConfirm() = PortfolioAnalyticsEvent( + event = "Add Token Screen Opened", + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun addToNotMainAccount() = PortfolioAnalyticsEvent( + event = "Button - Add To Account", + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun addButtonClick() = PortfolioAnalyticsEvent( + event = "Button - Add Token", + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent( + event = "Wallet Selected", + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun addToPortfolioContinue(blockchainNames: List) = PortfolioAnalyticsEvent( + event = "Token Network Selected", + params = buildMap { + put("Count", blockchainNames.size.toString()) + put("Token", tokenSymbol) + put("blockchain", blockchainNames.joinToString(separator = ", ")) + if (source != null) put("Source", source) + }, + ) + + fun tokenAdded(blockchainName: String) = PortfolioAnalyticsEvent( + event = "Token Added", + params = buildMap { + put("Token", tokenSymbol) + put("Blockchain", blockchainName) + if (source != null) put("Source", source) + }, + ) + + fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent( + event = when (actionUM) { + TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy" + TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive" + TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange" + TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake" + else -> "error" + }, + params = buildMap { + if (source != null) put("Source", source) + }, + ) + + fun getTokenLater() = PortfolioAnalyticsEvent( + event = "Popup Get token - Button Later", + params = buildMap { + if (source != null) put("Source", source) + }, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt similarity index 92% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt index 946ab12577..32f382fdcb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/converter/AvailableToAddDataConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.converter +package com.tangem.features.commonfeatures.impl.addtoportfolio.converter import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase @@ -13,9 +13,9 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddAccount -import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddData -import com.tangem.features.feed.components.market.details.portfolio.add.AvailableToAddWallet +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet import javax.inject.Inject internal class AvailableToAddDataConverter @Inject constructor( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt similarity index 79% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt index e4be2f3924..ed70205959 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/BlockchainRowUMConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt @@ -1,15 +1,14 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.converter import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.core.ui.extensions.getActiveIconRes import com.tangem.core.ui.extensions.getGreyedOutIconRes import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketInfo.Network import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.converter.Converter /** - * Converter from [TokenMarketInfo.Network] to [BlockchainRowUM] + * Converter from [com.tangem.domain.markets.TokenMarketInfo.Network] to [com.tangem.core.ui.components.rows.model.BlockchainRowUM] * * @property alreadyAddedNetworks set of already added networks * @@ -17,9 +16,9 @@ import com.tangem.utils.converter.Converter */ internal class BlockchainRowUMConverter( private val alreadyAddedNetworks: Set, -) : Converter, BlockchainRowUM> { +) : Converter, BlockchainRowUM> { - override fun convert(value: Pair): BlockchainRowUM { + override fun convert(value: Pair): BlockchainRowUM { val (network, isSelected) = value val blockchainInfo = BlockchainUtils.getNetworkInfo(networkId = network.networkId) @@ -48,7 +47,10 @@ internal class BlockchainRowUMConverter( ) } - private fun getNetworkType(network: Network, blockchainInfo: BlockchainUtils.BlockchainInfo): String { + private fun getNetworkType( + network: TokenMarketInfo.Network, + blockchainInfo: BlockchainUtils.BlockchainInfo, + ): String { val isMainNetwork = network.contractAddress == null return when { BlockchainUtils.isL2Network(networkId = network.networkId) -> MAIN_NETWORK_L2_TYPE_NAME diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt new file mode 100644 index 0000000000..871d7f7c44 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt @@ -0,0 +1,28 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.di + +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.DefaultAddToPortfolioManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface AddToPortfolioComponentModule { + + @Binds + fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory + + @Binds + fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory + + @Binds + fun bindAddToPortfolioPreselectedDataComponent( + factory: DefaultAddToPortfolioPreselectedDataComponent.Factory, + ): AddToPortfolioPreselectedDataComponent.Factory +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt similarity index 66% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt index 60b3f51d91..d0e2845d69 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioModelModule.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt @@ -1,8 +1,12 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.di +package com.tangem.features.commonfeatures.impl.addtoportfolio.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.* +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioPreselectedDataModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt similarity index 94% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index ce12bfc05a..dec0e1f72b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -1,9 +1,11 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.popToFirst import com.arkivanov.decompose.router.stack.pushNew import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.common.ui.markets.action.CryptoCurrencyData +import com.tangem.common.ui.markets.action.QuickActionsConverter.toQuickActions import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -19,22 +21,20 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.features.account.PortfolioSelectorController -import com.tangem.features.feed.components.market.details.portfolio.add.* -import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent -import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData -import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter.Companion.toQuickActions -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.api.addtoportfolio.* +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val TOKEN_ACTIONS_DELAY = 500L @@ -62,7 +62,7 @@ internal class AddToPortfolioModel @Inject constructor( /* Flows that hold state and provide it to child models */ val selectedNetwork: MutableSharedFlow = replayMutableSharedFlow() val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() - val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() + val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() private val addToPortfolioManager = params.addToPortfolioManager val portfolioFetcher = addToPortfolioManager.portfolioFetcher @@ -255,7 +255,7 @@ internal class AddToPortfolioModel @Inject constructor( private fun setupTokenActionsFlow( selectedPortfolio: SelectedPortfolio, addedToken: CryptoCurrencyStatus, - ): Flow { + ): Flow { val timeFlow = channelFlow { val timerJob = launch { delay(TOKEN_ACTIONS_DELAY) } getCryptoCurrencyActionsUseCase( @@ -275,7 +275,7 @@ internal class AddToPortfolioModel @Inject constructor( }.collect() } return timeFlow.map { actionsState -> - PortfolioData.CryptoCurrencyData( + CryptoCurrencyData( userWallet = selectedPortfolio.userWallet, status = actionsState.cryptoCurrencyStatus, actions = actionsState.states, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioPreselectedDataModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt similarity index 95% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioPreselectedDataModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt index a28a4fd173..10e935ada2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioPreselectedDataModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.replaceAll @@ -22,17 +22,19 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.account.PortfolioFetcher import com.tangem.features.account.PortfolioSelectorController -import com.tangem.features.feed.components.market.details.portfolio.add.* -import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent -import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent -import com.tangem.features.feed.impl.R -import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.* +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.EarnAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import java.math.BigDecimal import javax.inject.Inject +import kotlin.collections.get +import kotlin.collections.mapNotNull @Suppress("LongParameterList") internal class AddToPortfolioPreselectedDataModel @Inject constructor( @@ -163,7 +165,7 @@ internal class AddToPortfolioPreselectedDataModel @Inject constructor( } /** - * Creates [AvailableToAddData] for preselected network when token is already added in all networks. + * Creates [com.tangem.features.commonfeatures.api.AvailableToAddData] for preselected network when token is already added in all networks. * This allows user to select wallet/account and do smth after it with selected info. */ private suspend fun createAvailableToAddDataForPreselectedNetwork( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt similarity index 78% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt index a8c2acb3aa..e548f5b16f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddToPortfolioRoutes.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt @@ -1,8 +1,8 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import androidx.compose.runtime.Immutable import com.tangem.core.decompose.navigation.Route -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio import kotlinx.serialization.Serializable @Serializable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt similarity index 92% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt index 17d27f1aad..1ebff809de 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.tangem.common.ui.addtoken.AddTokenUM import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -13,11 +13,11 @@ import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.models.account.Account import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio -import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.model.AddTokenUiBuilder.Companion.toggleProgress -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenUiBuilder.Companion.toggleProgress +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio +import com.tangem.features.commonfeatures.impl.R import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt similarity index 91% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt index 2055a204ab..3498931b1d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/AddTokenUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter @@ -14,10 +14,10 @@ import com.tangem.core.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.AccountStatus.* -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedNetwork -import com.tangem.features.feed.components.market.details.portfolio.add.SelectedPortfolio -import com.tangem.features.feed.components.market.details.portfolio.add.impl.AddTokenComponent -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork +import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio +import com.tangem.features.commonfeatures.impl.R import javax.inject.Inject @ModelScoped diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/CheckCurrencyUnsupportedDelegate.kt similarity index 96% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/CheckCurrencyUnsupportedDelegate.kt index 8b32b2497b..2bc12e52e3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/CheckCurrencyUnsupportedDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/CheckCurrencyUnsupportedDelegate.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import arrow.core.getOrElse import com.tangem.core.decompose.ui.UiMessageSender @@ -10,7 +10,7 @@ import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.managetokens.CheckCurrencyUnsupportedUseCase import com.tangem.domain.managetokens.model.CurrencyUnsupportedState import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.impl.R import com.tangem.utils.logging.TangemLogger import javax.inject.Inject diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/ChooseNetworkModel.kt similarity index 85% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/ChooseNetworkModel.kt index 5dc3fc5a62..afc9170d74 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/ChooseNetworkModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/ChooseNetworkModel.kt @@ -1,13 +1,13 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ChooseNetworkComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM -import com.tangem.features.feed.components.market.details.portfolio.impl.model.BlockchainRowUMConverter +import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.BlockchainRowUMConverter +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.MutableStateFlow diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt similarity index 86% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt index 65e1cf28d1..2a9e691f46 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt @@ -1,7 +1,9 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.ui.markets.action.TokenActionsBSContentUM +import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -10,10 +12,8 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory -import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM -import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM +import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.SharingStarted diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt similarity index 65% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt index 8828010452..5b12b3efd4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/model/TokenActionsUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt @@ -1,16 +1,16 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.model +package com.tangem.features.commonfeatures.impl.addtoportfolio.model +import com.tangem.common.ui.markets.action.CryptoCurrencyData +import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions +import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.stringReference -import com.tangem.features.feed.components.market.details.portfolio.add.impl.TokenActionsComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData -import com.tangem.features.feed.components.market.details.portfolio.impl.model.PortfolioTokenUMConverter -import com.tangem.features.feed.components.market.details.portfolio.impl.model.TokenActionsHandler +import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM import javax.inject.Inject @ModelScoped @@ -20,7 +20,7 @@ internal class TokenActionsUiBuilder @Inject constructor( ) { private val params = paramsContainer.require() - fun build(data: PortfolioData.CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM { + fun build(data: CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM { val status = data.status val tokenUM = TokenItemState.Content( id = status.currency.id.value, @@ -38,7 +38,7 @@ internal class TokenActionsUiBuilder @Inject constructor( analyticsEventHandler.send(params.eventBuilder.getTokenLater()) params.callbacks.onLaterClick() }, - quickActions = PortfolioTokenUMConverter.quickActions(data, tokenActionsHandler), + quickActions = quickActions(data, tokenActionsHandler), ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt similarity index 94% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt index 7b92e78e3b..a682ee6396 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/ChooseNetworkContent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui import android.content.res.Configuration import androidx.compose.foundation.background @@ -24,8 +24,8 @@ import com.tangem.core.ui.components.rows.model.BlockchainRowUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.ChooseNetworkUM -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM +import com.tangem.features.commonfeatures.impl.R import kotlinx.collections.immutable.persistentListOf import java.util.UUID diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt similarity index 90% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt index 8c27a3929d..0093289a88 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/DefaultAddToPortfolioManager.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt @@ -1,10 +1,10 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager -import com.tangem.features.feed.components.market.details.portfolio.add.impl.converter.AvailableToAddDataConverter +import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.AvailableToAddDataConverter +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt similarity index 94% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt index d88388bb30..23d8df105c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/TokenActionsContent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui import android.content.res.Configuration import androidx.compose.foundation.ExperimentalFoundationApi @@ -22,6 +22,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.markets.action.QuickActionUM +import com.tangem.common.ui.markets.action.QuickActions import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH16 @@ -37,10 +39,8 @@ import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state.TokenActionsUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM -import com.tangem.features.feed.impl.R +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM import kotlinx.collections.immutable.persistentListOf import java.util.UUID @@ -188,7 +188,7 @@ private class TokenActionsContentPreviewProvider : PreviewParameterProvider get() = sequenceOf( TokenActionsUM( - quickActions = PortfolioTokenUM.QuickActions( + quickActions = QuickActions( actions = persistentListOf( QuickActionUM.Buy, QuickActionUM.Exchange(shouldShowBadge = true), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/ChooseNetworkUM.kt similarity index 73% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/ChooseNetworkUM.kt index 8f54e3cb3b..89fde91bde 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/ChooseNetworkUM.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/ChooseNetworkUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state import com.tangem.core.ui.components.rows.model.BlockchainRowUM import kotlinx.collections.immutable.ImmutableList diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt new file mode 100644 index 0000000000..b0b0b9b56e --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state + +import com.tangem.common.ui.markets.action.QuickActions +import com.tangem.core.ui.components.token.state.TokenItemState + +internal data class TokenActionsUM( + val token: TokenItemState, + val quickActions: QuickActions, + val onLaterClick: () -> Unit, +) \ No newline at end of file diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index 41a5739506..f5d80f4224 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { api(projects.features.tokenRecieve.api) api(projects.features.wallet.api) api(projects.features.account.api) + api(projects.features.commonFeatures.api) implementation(projects.features.promoBanners.api) /* Data */ diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index fb93121de6..ef34cba3d1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -11,7 +11,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index ac8f5fffdc..86bb48edfb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -34,7 +34,7 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.model.earn.EarnModel import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.earn.EarnContent diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index 4f06480af4..24bd9e7eb6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -19,8 +19,8 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent.Params +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent.Params import com.tangem.features.feed.model.feed.FeedComponentModel import com.tangem.features.feed.model.feed.FeedModelClickIntents import com.tangem.features.feed.ui.feed.FeedList diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt index fea9189d03..ecbaa6c87a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt @@ -2,7 +2,7 @@ package com.tangem.features.feed.components.feed import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent import com.tangem.features.feed.components.earn.EarnTypeFilterComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent internal sealed interface FeedBottomSheetRoute { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt deleted file mode 100644 index 1233c2be6a..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/di/AddToPortfolioComponentModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.di - -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.DefaultAddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.DefaultAddToPortfolioManager -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -internal interface AddToPortfolioComponentModule { - - @Binds - fun bindAddToPortfolioComponent(factory: DefaultAddToPortfolioComponent.Factory): AddToPortfolioComponent.Factory - - @Binds - fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory - - @Binds - fun bindAddToPortfolioPreselectedDataComponent( - factory: DefaultAddToPortfolioPreselectedDataComponent.Factory, - ): AddToPortfolioPreselectedDataComponent.Factory -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt deleted file mode 100644 index d90e3ca130..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/add/impl/ui/state/TokenActionsUM.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.add.impl.ui.state - -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM - -internal data class TokenActionsUM( - val token: TokenItemState, - val quickActions: PortfolioTokenUM.QuickActions, - val onLaterClick: () -> Unit, -) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt index a1cf255e9b..b10ad7940d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -14,7 +14,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioModel import com.tangem.features.feed.components.market.details.portfolio.impl.model.MarketsPortfolioRoute diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt index 87ed9607b7..80c831f780 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/analytics/PortfolioAnalyticsEvent.kt @@ -1,7 +1,7 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.analytics +import com.tangem.common.ui.markets.action.TokenActionsBSContentUM import com.tangem.core.analytics.models.AnalyticsEvent -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM internal class PortfolioAnalyticsEvent( event: String, @@ -21,60 +21,6 @@ internal class PortfolioAnalyticsEvent( }, ) - fun popupToChooseAccount() = PortfolioAnalyticsEvent( - event = "Choose Account Opened", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun popupToConfirm() = PortfolioAnalyticsEvent( - event = "Add Token Screen Opened", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun addToNotMainAccount() = PortfolioAnalyticsEvent( - event = "Button - Add To Account", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun addButtonClick() = PortfolioAnalyticsEvent( - event = "Button - Add Token", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent( - event = "Wallet Selected", - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun addToPortfolioContinue(blockchainNames: List) = PortfolioAnalyticsEvent( - event = "Token Network Selected", - params = buildMap { - put("Count", blockchainNames.size.toString()) - put("Token", tokenSymbol) - put("blockchain", blockchainNames.joinToString(separator = ", ")) - if (source != null) put("Source", source) - }, - ) - - fun tokenAdded(blockchainName: String) = PortfolioAnalyticsEvent( - event = "Token Added", - params = buildMap { - put("Token", tokenSymbol) - put("Blockchain", blockchainName) - if (source != null) put("Source", source) - }, - ) - fun quickActionClick(actionUM: TokenActionsBSContentUM.Action, blockchainName: String) = PortfolioAnalyticsEvent( event = when (actionUM) { @@ -91,25 +37,5 @@ internal class PortfolioAnalyticsEvent( put("blockchain", blockchainName) }, ) - - fun getTokenActionClick(actionUM: TokenActionsBSContentUM.Action) = PortfolioAnalyticsEvent( - event = when (actionUM) { - TokenActionsBSContentUM.Action.Buy -> "Popup Get token - Button Buy" - TokenActionsBSContentUM.Action.Receive -> "Popup Get token - Button Receive" - TokenActionsBSContentUM.Action.Exchange -> "Popup Get token - Button Exchange" - TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake" - else -> "error" - }, - params = buildMap { - if (source != null) put("Source", source) - }, - ) - - fun getTokenLater() = PortfolioAnalyticsEvent( - event = "Popup Get token - Button Later", - params = buildMap { - if (source != null) put("Source", source) - }, - ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt deleted file mode 100644 index 044a0ef229..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/loader/PortfolioData.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolio.impl.loader - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.core.lce.Lce -import com.tangem.domain.models.TotalFiatBalance -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.tokens.error.TokenListError -import com.tangem.domain.tokens.model.TokenActionsState - -/** - * Portfolio data. Combined data from all flows that required to setup portfolio - * - * @property walletsWithCurrencies wallets with crypto currency statuses - * @property appCurrency app currency - * @property isBalanceHidden flag that indicates if balance should be hidden - * @property walletsWithBalance wallets with total balance - * -[REDACTED_AUTHOR] - */ -internal data class PortfolioData( - val walletsWithCurrencies: Map>, - val appCurrency: AppCurrency, - val isBalanceHidden: Boolean, - val walletsWithBalance: Map>, -) { - data class CryptoCurrencyData( - val userWallet: UserWallet, - val status: CryptoCurrencyStatus, - val actions: List, - ) -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt index e1ed13a89e..0b21fb11f3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt @@ -5,6 +5,8 @@ import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.markets.action.CryptoCurrencyData +import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.models.AccountStatusList @@ -28,7 +30,6 @@ import com.tangem.domain.tokens.model.TokenActionsState import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.models.YieldSupplyAvailability import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioHeader import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioListItem @@ -248,7 +249,7 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor( accountWithAdded.addedCurrency.forEach { currencyStatus -> val actions = allActions[currencyStatus.currency]?.states.orEmpty() - val value = PortfolioData.CryptoCurrencyData( + val value = CryptoCurrencyData( userWallet = userWallet, status = currencyStatus, actions = actions, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt index 0401beb1e7..93b2b5629a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt @@ -5,6 +5,8 @@ import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.ui.markets.action.TokenActionsBSContentUM +import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -14,12 +16,11 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.MyPortfolioUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt index 2a078e38ed..5474899d5c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -1,19 +1,16 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.model +import com.tangem.common.ui.markets.action.CryptoCurrencyData +import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions +import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.stringReference 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.tokens.model.ScenarioUnavailabilityReason -import com.tangem.domain.tokens.model.TokenActionsState -import com.tangem.features.feed.components.market.details.portfolio.impl.loader.PortfolioData import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.PortfolioTokenUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.TokenActionsBSContentUM import com.tangem.utils.converter.Converter -import kotlinx.collections.immutable.toImmutableList /** * Converter from [UserWallet] and [CryptoCurrencyStatus] to [PortfolioTokenUM] @@ -25,10 +22,10 @@ internal class PortfolioTokenUMConverter( private val isBalanceHidden: Boolean, private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit, private val tokenActionsHandler: TokenActionsHandler, -) : Converter { +) : Converter { fun convertV2( - value: PortfolioData.CryptoCurrencyData, + value: CryptoCurrencyData, isQuickActionsShown: Boolean, onTokenItemClick: (UserWallet, CryptoCurrencyStatus) -> Unit, ): PortfolioTokenUM { @@ -45,7 +42,7 @@ internal class PortfolioTokenUMConverter( ) } - override fun convert(value: PortfolioData.CryptoCurrencyData): PortfolioTokenUM { + override fun convert(value: CryptoCurrencyData): PortfolioTokenUM { val tokenItemStateConverter = TokenItemStateConverter( appCurrency = appCurrency, titleStateProvider = { TokenItemState.TitleState.Content(text = stringReference(value.userWallet.name)) }, @@ -63,62 +60,4 @@ internal class PortfolioTokenUMConverter( quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), ) } - - companion object { - fun quickActions( - cryptoData: PortfolioData.CryptoCurrencyData, - tokenActionsHandler: TokenActionsHandler, - ): PortfolioTokenUM.QuickActions { - return PortfolioTokenUM.QuickActions( - actions = toQuickActions(cryptoData.actions), - onQuickActionClick = { quickActionUM -> - when (quickActionUM) { - QuickActionUM.Buy -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Buy, - cryptoCurrencyData = cryptoData, - ) - is QuickActionUM.Exchange -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Exchange, - cryptoCurrencyData = cryptoData, - ) - QuickActionUM.Receive -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Receive, - cryptoCurrencyData = cryptoData, - ) - QuickActionUM.Stake -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.Stake, - cryptoCurrencyData = cryptoData, - ) - is QuickActionUM.YieldMode -> tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.YieldMode, - cryptoCurrencyData = cryptoData, - ) - } - }, - onQuickActionLongClick = { actionUM -> - if (actionUM == QuickActionUM.Receive) { - tokenActionsHandler.handle( - action = TokenActionsBSContentUM.Action.CopyAddress, - cryptoCurrencyData = cryptoData, - ) - } - }, - ) - } - - fun toQuickActions(actions: List) = buildList { - actions.forEach { action -> - if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { - when (action) { - is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy - is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.shouldShowBadge) - is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive - is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake - is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy) - else -> null - }?.let(::add) - } - } - }.toImmutableList() - } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt index 2260f52c5d..b1ad93e9b2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.markets.action.QuickActionUM import com.tangem.core.ui.components.SpacerH4 import com.tangem.core.ui.components.icons.badge.drawBadge import com.tangem.core.ui.extensions.resolveReference @@ -36,7 +37,6 @@ import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags -import com.tangem.features.feed.components.market.details.portfolio.impl.ui.state.QuickActionUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt index f84d983885..ebc36fbbd5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt @@ -3,6 +3,8 @@ package com.tangem.features.feed.components.market.details.portfolio.impl.ui.pre import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.tangem.common.ui.account.AccountIconPreviewData import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.markets.action.QuickActionUM +import com.tangem.common.ui.markets.action.QuickActions import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState @@ -125,7 +127,7 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider, - val onQuickActionClick: (QuickActionUM) -> Unit, - val onQuickActionLongClick: (QuickActionUM) -> Unit, - ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index ac71423894..b69171f8f3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -25,7 +25,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent import com.tangem.features.feed.components.earn.EarnTypeFilterComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkConverter import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkUMConverter diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt index 3ff24b4467..5369759688 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt @@ -50,30 +50,6 @@ internal sealed class EarnAnalyticsEvent( ), ) - data class AddTokenScreenOpened( - private val tokenSymbol: String, - private val blockchain: String, - private val source: String, - ) : EarnAnalyticsEvent( - event = "Add Token Screen Opened", - params = mapOf( - AnalyticsParam.TOKEN_PARAM to tokenSymbol, - AnalyticsParam.BLOCKCHAIN to blockchain, - AnalyticsParam.SOURCE to source, - ), - ) - - data class TokenAdded( - private val tokenSymbol: String, - private val blockchain: String, - ) : EarnAnalyticsEvent( - event = "Token Added", - params = mapOf( - AnalyticsParam.TOKEN_PARAM to tokenSymbol, - AnalyticsParam.BLOCKCHAIN to blockchain, - ), - ) - data class BestOpportunitiesLoadError( private val code: Int?, private val message: String, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 33fc8bb216..170b29bdae 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -31,7 +31,7 @@ import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioPreselectedDataComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 4c6b3a90a3..4d66407671 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -12,6 +12,9 @@ android { } dependencies { + /** Api */ + implementation(projects.features.commonFeatures.api) + /** Project - API */ implementation(projects.features.account.api) implementation(projects.features.onramp.api) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt index ee2dc2dd5b..f09d27300f 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt @@ -15,7 +15,7 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.onramp.component.SwapSelectTokensComponent import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt index bae0b5ba51..e8d885de0e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt @@ -7,8 +7,8 @@ import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.decompose.ComposableListContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.tokenlist.entity.TokenListUM import kotlinx.coroutines.flow.StateFlow diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt index 27e528cd04..23816a7b3a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt @@ -6,8 +6,8 @@ import androidx.compose.ui.Modifier import com.arkivanov.decompose.router.slot.SlotNavigation import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel import com.tangem.features.onramp.tokenlist.entity.TokenListUM diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index e949b69cf7..cd1e38a700 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -41,8 +41,8 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.domain.GetAvailablePairsUseCase import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.feature.swap.domain.models.domain.SwapPairLeast -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index 6cd1167b8e..ca51e0e384 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -12,6 +12,9 @@ android { } dependencies { + /** Api */ + implementation(projects.features.commonFeatures.api) + /** Core modules */ implementation(projects.core.analytics) implementation(projects.core.analytics.models) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt index 62bd2f26e5..b92904c2d7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt @@ -15,15 +15,15 @@ import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel import com.tangem.feature.swap.models.AddToPortfolioRoute import com.tangem.feature.swap.ui.SwapSelectTokenScreen -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject internal class DefaultChooseTokenComponent @AssistedInject constructor( + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, @Assisted appComponentContext: AppComponentContext, @Assisted private val params: ChooseTokenComponent.Params, - private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, ) : AppComponentContext by appComponentContext, ChooseTokenComponent { private val model: ChooseTokenModel = getOrCreateModel(params) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt index 0766482b98..12459d97e7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt @@ -23,7 +23,7 @@ import com.tangem.feature.swap.converters.TokensDataConverter import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt index 0a0340e417..2c66851ed2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt @@ -17,7 +17,7 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.models.AddToPortfolioRoute import com.tangem.feature.swap.models.market.MarketsListBatchFlowManager import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.coroutines.JobHolder @@ -32,9 +32,9 @@ import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class MarketBlockDelegate @AssistedInject constructor( private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory, - private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val excludedBlockchains: ExcludedBlockchains, private val getUserWalletsUseCase: GetWalletsUseCase, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, @Assisted private val modelScope: CoroutineScope, @Assisted private val searchQueryState: StateFlow, @Assisted private val screensSourcesName: String, diff --git a/settings.gradle.kts b/settings.gradle.kts index 35213a3a98..1938b7587c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -321,6 +321,9 @@ include(":features:virtual-accounts:main:impl") include(":features:virtual-accounts:details:api") include(":features:virtual-accounts:details:impl") + +include(":features:common-features:api") +include(":features:common-features:impl") // endregion Feature modules // region Domain modules From 11d218a155249029c82e1f11f88ccb6a37711144 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Apr 2026 17:10:04 +0400 Subject: [PATCH 019/206] Updated on 2026-08-14 --- .../tangem/datasource/api/pay/TangemPayApi.kt | 6 + .../request/UpdateCardDisplayNameRequest.kt | 9 + .../pay/models/response/CustomerMeResponse.kt | 1 + .../response/UpdateCardDisplayNameResponse.kt | 14 ++ .../entity/PaymentAccountStatusValueDM.kt | 1 + .../PaymentAccountStatusValueDMConverter.kt | 6 + .../DefaultPaymentAccountStatusFetcher.kt | 2 + .../repository/DefaultOnboardingRepository.kt | 11 +- .../DefaultTangemPayCardDetailsRepository.kt | 22 +++ .../domain/models/account/CardDisplayName.kt | 36 ++++ .../account/PaymentAccountStatusValue.kt | 2 + .../domain/pay/TangemPayDetailsConfig.kt | 2 + .../tangem/domain/pay/model/CustomerInfo.kt | 2 + .../TangemPayCardDetailsRepository.kt | 6 + .../DefaultTangemPayCardPageComponent.kt | 9 +- ...faultTangemPayDetailsContainerComponent.kt | 7 + .../TangemPayAddToWalletComponent.kt | 5 +- .../TangemPayCardPageScreenComponent.kt | 5 +- .../components/TangemPayDetailsComponent.kt | 5 +- .../TangemPayEditDisplayNameComponent.kt | 51 ++++++ .../TangemPayCardDetailsBlockComponent.kt | 5 +- .../tangempay/di/TangemPayModelModule.kt | 6 + .../TangemPayCardDetailsBlockStateFactory.kt | 2 + .../tangempay/entity/TangemPayDetailsUM.kt | 19 ++ .../entity/TangemPayEditDisplayNameUM.kt | 9 + .../model/TangemPayCardDetailsBlockModel.kt | 20 ++- .../model/TangemPayEditDisplayNameModel.kt | 92 ++++++++++ .../navigation/TangemPayDetailsInnerRoute.kt | 3 + .../ui/TangemPayAddToWalletScreen.kt | 1 + .../tangempay/ui/TangemPayCardDetailsBlock.kt | 168 +++++++++++++++++- .../tangempay/ui/TangemPayCardPageScreen.kt | 52 ++++-- .../tangempay/ui/TangemPayDetailsScreen.kt | 2 + .../ui/TangemPayEditDisplayNameScreen.kt | 82 +++++++++ .../TangemPayUpdateInfoStateTransformer.kt | 1 + .../converter/TangemPayMainBlockConverter.kt | 2 + 35 files changed, 636 insertions(+), 30 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/UpdateCardDisplayNameResponse.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/account/CardDisplayName.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 8f67d01dae..3c1f040b64 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -96,4 +96,10 @@ interface TangemPayApi { @Header("Authorization") authHeader: String, @Body body: WithdrawRequest, ): ApiResponse + + @PATCH("v1/card") + suspend fun updateCardDisplayName( + @Header("Authorization") authHeader: String, + @Body body: UpdateCardDisplayNameRequest, + ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.kt new file mode 100644 index 0000000000..b5c9e74e58 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UpdateCardDisplayNameRequest( + @Json(name = "display_name") val displayName: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 38e8982664..906a69fa12 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -29,6 +29,7 @@ data class CustomerMeResponse( @Json(name = "status") val status: Status, @Json(name = "updated_at") val updatedAt: String, @Json(name = "payment_account_id") val paymentAccountId: String, + @Json(name = "display_name") val displayName: String?, ) { @JsonClass(generateAdapter = false) enum class Status { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/UpdateCardDisplayNameResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/UpdateCardDisplayNameResponse.kt new file mode 100644 index 0000000000..feb64ea566 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/UpdateCardDisplayNameResponse.kt @@ -0,0 +1,14 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UpdateCardDisplayNameResponse( + @Json(name = "result") val result: Result?, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "display_name") val displayName: String?, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 7b1ca88f44..98bfec8c62 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -43,6 +43,7 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "is_pin_set") val isPinSet: Boolean, @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, + @Json(name = "display_name") val displayName: String?, ) : PaymentAccountStatusValueDM @NameLabel("card_issue_failed") diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 57eb9b4402..cfa7822955 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -1,8 +1,10 @@ package com.tangem.data.pay.converter import com.tangem.data.pay.entity.TangemPayCurrencyFactory +import arrow.core.getOrElse import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.wallet.UserWalletId import javax.inject.Inject @@ -39,6 +41,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDM(), cryptoBalance = value.cryptoBalance.toDM(), + displayName = value.displayName?.value, ) is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveCard( isLocked = false, @@ -50,6 +53,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDM(), cryptoBalance = value.cryptoBalance.toDM(), + displayName = value.displayName?.value, ) is PaymentAccountStatusValue.Error.CardIssueFailed -> PaymentAccountStatusValueDM.CardIssueFailed( customerId = value.customerId, @@ -84,6 +88,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + displayName = value.displayName?.let { CardDisplayName(it).getOrElse { null } }, ) } else { PaymentAccountStatusValue.Loaded( @@ -97,6 +102,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + displayName = value.displayName?.let { CardDisplayName(it).getOrElse { null } }, ) } is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 9e0ae38e91..0f48c011ce 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -234,6 +234,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, cryptoCurrency = cryptoCurrency, + displayName = productInstance.displayName, ) else -> PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, @@ -246,6 +247,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, cryptoCurrency = cryptoCurrency, + displayName = productInstance.displayName, ) } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 8253961561..600f2f3ebc 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -2,6 +2,7 @@ package com.tangem.data.pay.repository import arrow.core.Either import arrow.core.flatMap +import arrow.core.getOrElse import arrow.core.left import arrow.core.right import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -15,6 +16,7 @@ import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.TangemPayEligibilityType +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.wallet.UserWallet @@ -198,7 +200,14 @@ internal class DefaultOnboardingRepository @Inject constructor( } cardFrozenStateStore.store(key = instance.cardId, value = cardFrozenState) - ProductInstance(id = instance.id, cardId = instance.cardId, frozenState = cardFrozenState) + val displayName = instance.displayName?.ifEmpty { null } + + ProductInstance( + id = instance.id, + cardId = instance.cardId, + frozenState = cardFrozenState, + displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null, + ) } return CustomerInfo( customerId = response?.id, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt index 88f54d413e..814476fa34 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -15,10 +15,12 @@ import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.CardDetailsRequest import com.tangem.datasource.api.pay.models.request.FreezeUnfreezeCardRequest import com.tangem.datasource.api.pay.models.request.SetPinRequest +import com.tangem.datasource.api.pay.models.request.UpdateCardDisplayNameRequest import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse import com.tangem.datasource.api.pay.models.response.OrderResponse.Result.Status import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance @@ -318,6 +320,26 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( } } + override suspend fun updateCardDisplayName( + userWalletId: UserWalletId, + displayName: CardDisplayName, + ): Either { + return catch( + block = { + requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.updateCardDisplayName( + authHeader = authHeader, + body = UpdateCardDisplayNameRequest(displayName = displayName.value), + ) + }.fold( + ifLeft = { error -> error.left() }, + ifRight = { Unit.right() }, + ) + }, + catch = ::catchException, + ) + } + override fun cardFrozenState(cardId: String): Flow { return cardFrozenStateStore.get(cardId) } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/CardDisplayName.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/CardDisplayName.kt new file mode 100644 index 0000000000..eb0bd72a9c --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/CardDisplayName.kt @@ -0,0 +1,36 @@ +package com.tangem.domain.models.account + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.raise.ensure +import kotlinx.serialization.Serializable + +@Serializable +@ConsistentCopyVisibility +data class CardDisplayName private constructor(val value: String) { + + @Serializable + sealed interface Error { + @Serializable + data object Empty : Error + + @Serializable + data object ExceedsMaxLength : Error + + @Serializable + data object InvalidCharacters : Error + } + + companion object { + const val MAX_LENGTH = 20 + private val allowedPattern = Regex("^[\\p{L}\\p{N} ]+$") + + operator fun invoke(name: String): Either = either { + val trimmed = name.trim() + ensure(trimmed.isNotEmpty()) { Error.Empty } + ensure(trimmed.length <= MAX_LENGTH) { Error.ExceedsMaxLength } + ensure(allowedPattern.matches(trimmed)) { Error.InvalidCharacters } + CardDisplayName(trimmed) + } + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index 53beaf23be..8e74069ff6 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -109,6 +109,7 @@ sealed class PaymentAccountStatusValue { val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, val cryptoCurrency: CryptoCurrency.Token, + val displayName: CardDisplayName?, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, @@ -157,6 +158,7 @@ sealed class PaymentAccountStatusValue { val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, val cryptoCurrency: CryptoCurrency.Token, + val displayName: CardDisplayName?, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt index e2424a061a..5b2be89b69 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt @@ -1,5 +1,6 @@ package com.tangem.domain.pay +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.serialization.Serializable @@ -11,4 +12,5 @@ data class TangemPayDetailsConfig( val cardFrozenState: TangemPayCardFrozenState, val cardNumberEnd: String, val chainId: Int, + val displayName: CardDisplayName?, ) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index a35e9adaea..7c93887879 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -1,5 +1,6 @@ package com.tangem.domain.pay.model +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.visa.model.TangemPayCardFrozenState @@ -49,6 +50,7 @@ data class CustomerInfo( val id: String, val cardId: String, val frozenState: TangemPayCardFrozenState, + val displayName: CardDisplayName?, ) data class CardInfo( diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt index 088b34d193..80aa3ea5c9 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt @@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance @@ -31,4 +32,9 @@ interface TangemPayCardDetailsRepository { fun cardFrozenState(cardId: String): Flow suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? + + suspend fun updateCardDisplayName( + userWalletId: UserWalletId, + displayName: CardDisplayName, + ): Either } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt index a69dbe675f..7b9e24e79b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.stack.Children -import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack @@ -45,7 +44,6 @@ internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( val childStack by childStack.subscribeAsState() Children( stack = childStack, - animation = stackAnimation(), ) { child -> child.instance.Content(modifier = modifier) } @@ -76,6 +74,13 @@ internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( config = params.config, ), ) + TangemPayDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index baa27a447b..35ea8d9089 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -75,6 +75,13 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = params, ) + TangemPayDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt index 561ef1f9cf..91d394396e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt @@ -23,7 +23,10 @@ internal class TangemPayAddToWalletComponent( private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( appComponentContext = child("cardDetailsBlockComponent"), - params = TangemPayCardDetailsBlockComponent.Params(params = params), + params = TangemPayCardDetailsBlockComponent.Params( + params = params, + isDisplayCardNameEnabled = false, + ), ) @Composable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt index 975ce82cf3..e0f2994b3c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -34,7 +34,10 @@ internal class TangemPayCardPageScreenComponent( private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( appComponentContext = child("cardDetailsBlockComponent"), - params = TangemPayCardDetailsBlockComponent.Params(params = containerParams), + params = TangemPayCardDetailsBlockComponent.Params( + params = containerParams, + isDisplayCardNameEnabled = true, + ), ) private val bottomSheetSlot = childSlot( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 58f5d9ef84..c193b44545 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -51,7 +51,10 @@ internal class TangemPayDetailsComponent( private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( appComponentContext = child("cardDetailsBlockComponent"), - params = TangemPayCardDetailsBlockComponent.Params(params = params), + params = TangemPayCardDetailsBlockComponent.Params( + params = params, + isDisplayCardNameEnabled = false, + ), ) private val expressTransactionsComponent by lazy { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt new file mode 100644 index 0000000000..8b4bb6214d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt @@ -0,0 +1,51 @@ +package com.tangem.features.tangempay.components + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.entity.DisplayNameState +import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel +import com.tangem.features.tangempay.ui.TangemPayEditDisplayNameScreen + +internal class TangemPayEditDisplayNameComponent( + private val appComponentContext: AppComponentContext, + params: TangemPayDetailsContainerComponent.Params, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayEditDisplayNameModel = getOrCreateModel(params) + + private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( + appComponentContext = child("editDisplayNameCardDetails"), + params = TangemPayCardDetailsBlockComponent.Params(params = params, isDisplayCardNameEnabled = true), + ) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() + val editingCardDetailsState = cardDetailsState.copy( + displayNameState = DisplayNameState.Editing( + displayName = state.editingValue, + editingValue = state.editingValue, + onValueChanged = state.onValueChanged, + onSubmit = state.onDoneClick, + onDismiss = state.onDismiss, + ), + ) + BackHandler(onBack = state.onDismiss) + TangemPayEditDisplayNameScreen( + state = state, + cardDetailsBlockComponent = cardDetailsBlockComponent, + cardDetailsState = editingCardDetailsState, + modifier = modifier, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt index 9fb41015c0..9b80de3eb5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt @@ -14,5 +14,8 @@ internal interface TangemPayCardDetailsBlockComponent { @Composable fun CardDetailsBlockContent(state: TangemPayCardDetailsUM, modifier: Modifier) - data class Params(val params: TangemPayDetailsContainerComponent.Params) + data class Params( + val params: TangemPayDetailsContainerComponent.Params, + val isDisplayCardNameEnabled: Boolean, + ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index d533bce2b9..623eea9c2a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -8,6 +8,7 @@ import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel import com.tangem.features.tangempay.model.TangemPayCardPageModel import com.tangem.features.tangempay.model.TangemPayChangePinModel import com.tangem.features.tangempay.model.TangemPayDetailsModel +import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel import com.tangem.features.tangempay.model.TangemPayTxHistoryModel import com.tangem.features.tangempay.model.TangemPayViewPinModel @@ -65,4 +66,9 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPayCardPageModel::class) fun bindTangemPayCardPageModel(model: TangemPayCardPageModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayEditDisplayNameModel::class) + fun bindTangemPayEditDisplayNameModel(model: TangemPayEditDisplayNameModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt index e79a05f253..d57030a177 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -8,6 +8,7 @@ import com.tangem.utils.StringsSigns internal class TangemPayCardDetailsBlockStateFactory( private val cardNumberEnd: String, + private val displayNameState: DisplayNameState?, private val onReveal: () -> Unit, private val onCopy: (String, CardDataType) -> Unit, ) { @@ -22,5 +23,6 @@ internal class TangemPayCardDetailsBlockStateFactory( onCopy = onCopy, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = displayNameState, ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index f67ed6b7b0..5660713341 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -30,8 +30,27 @@ internal data class TangemPayCardDetailsUM( val isHidden: Boolean = true, val isLoading: Boolean = false, val cardFrozenState: TangemPayCardFrozenState, + val displayNameState: DisplayNameState?, ) +internal sealed interface DisplayNameState { + + val displayName: String + + data class Display( + override val displayName: String, + val onClick: () -> Unit, + ) : DisplayNameState + + data class Editing( + override val displayName: String, + val editingValue: String, + val onValueChanged: (String) -> Unit, + val onSubmit: () -> Unit, + val onDismiss: () -> Unit, + ) : DisplayNameState +} + internal sealed class TangemPayDetailsBalanceBlockState { abstract val actionButtons: ImmutableList diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt new file mode 100644 index 0000000000..a13fdbc1d0 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt @@ -0,0 +1,9 @@ +package com.tangem.features.tangempay.entity + +internal data class TangemPayEditDisplayNameUM( + val editingValue: String, + val isLoading: Boolean, + val onValueChanged: (String) -> Unit, + val onDoneClick: () -> Unit, + val onDismiss: () -> Unit, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index fcee864f89..13025f10a1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler 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.clipboard.ClipboardManager import com.tangem.core.ui.extensions.TextReference @@ -13,13 +14,13 @@ import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.model.listener.CardDetailsEvent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener -import com.tangem.features.tangempay.model.transformers.DetailsHiddenStateTransformer -import com.tangem.features.tangempay.model.transformers.DetailsRevealProgressStateTransformer -import com.tangem.features.tangempay.model.transformers.DetailsRevealedStateTransformer +import com.tangem.features.tangempay.model.transformers.* +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -42,12 +43,21 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val uiMessageSender: UiMessageSender, private val cardDetailsEventListener: CardDetailsEventListener, private val analytics: AnalyticsEventHandler, + private val router: Router, ) : Model() { private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require() private val stateFactory = TangemPayCardDetailsBlockStateFactory( cardNumberEnd = params.params.config.cardNumberEnd, + displayNameState = if (params.isDisplayCardNameEnabled && params.params.config.displayName != null) { + DisplayNameState.Display( + displayName = requireNotNull(params.params.config.displayName).value, + onClick = ::startEditingDisplayName, + ) + } else { + null + }, onReveal = ::revealCardDetails, onCopy = ::copyData, ) @@ -120,6 +130,10 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( ) } + private fun startEditingDisplayName() { + router.push(TangemPayDetailsInnerRoute.EditCardDisplayName) + } + private fun copyData(text: String, type: CardDataType) { val event = when (type) { CardDataType.Number -> TangemPayAnalyticsEvents.CopyCardNumberClicked() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt new file mode 100644 index 0000000000..a559c7a3d9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -0,0 +1,92 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +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.extensions.TextReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.account.CardDisplayName +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayEditDisplayNameModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val uiMessageSender: UiMessageSender, +) : Model() { + + private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + + private val originalDisplayName = params.config.displayName?.value.orEmpty() + + val uiState: StateFlow + field = MutableStateFlow( + TangemPayEditDisplayNameUM( + editingValue = originalDisplayName, + isLoading = false, + onValueChanged = ::onValueChanged, + onDoneClick = ::onDoneClick, + onDismiss = ::onDismiss, + ), + ) + + private fun onValueChanged(value: String) { + if (value.length <= CardDisplayName.MAX_LENGTH) { + uiState.update { it.copy(editingValue = value) } + } + } + + private fun onDoneClick() { + val currentValue = uiState.value.editingValue + if (currentValue.trim() == originalDisplayName.trim()) { + router.pop() + return + } + CardDisplayName(currentValue) + .onRight { cardDisplayName -> + uiState.update { it.copy(isLoading = true) } + modelScope.launch { + cardDetailsRepository.updateCardDisplayName(params.userWalletId, cardDisplayName) + .onRight { router.pop() } + .onLeft { + uiState.update { state -> state.copy(isLoading = false) } + showError( + titleRes = R.string.tangem_pay_card_details_unable_to_rename_card_title, + messageRes = R.string.tangempay_card_details_unable_to_rename_card_description, + ) + } + } + } + .onLeft { + showError( + titleRes = R.string.tangempay_card_details_rename_card_invalid_title, + messageRes = R.string.tangempay_card_details_rename_card_invalid_description, + ) + } + } + + private fun showError(titleRes: Int, messageRes: Int) { + uiMessageSender.send( + DialogMessage(title = TextReference.Res(titleRes), message = TextReference.Res(messageRes)), + ) + } + + private fun onDismiss() { + router.pop() + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt index 7bc24c9979..168ea88da5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt @@ -16,4 +16,7 @@ internal sealed class TangemPayDetailsInnerRoute : Route { @Serializable data object AddToWallet : TangemPayDetailsInnerRoute() + + @Serializable + data object EditCardDisplayName : TangemPayDetailsInnerRoute() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt index 7fd6c66c61..6db50884f3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayAddToWalletScreen.kt @@ -191,6 +191,7 @@ private fun PreviewTangemPayAddToWalletScreen() { onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = null, ), ), ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 33e91f7745..9cc9ea437d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -1,23 +1,46 @@ package com.tangem.features.tangempay.ui +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider @@ -32,9 +55,12 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.model.CardDataType +import com.tangem.domain.models.account.CardDisplayName +private const val ICON_FADE_DURATION_MS = 300 private val CustomCardBlockColor = Color(0x1F828282) @Suppress("MagicNumber") @@ -92,17 +118,20 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M isLoading = state.isLoading, shortCardNumber = state.numberShort, onShowDetails = state.onClick, + displayNameState = state.displayNameState, ) } } } +@Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries") @Composable private fun TangemPayCardDetailsHiddenBlock( shortCardNumber: String, cardFrozenState: TangemPayCardFrozenState, isLoading: Boolean, onShowDetails: () -> Unit, + displayNameState: DisplayNameState?, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { @@ -115,22 +144,46 @@ private fun TangemPayCardDetailsHiddenBlock( painter = painterResource(id = imageResId), contentDescription = null, ) - Row( + ConstraintLayout( modifier = Modifier .align(Alignment.BottomCenter) .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, ) { + val (displayNameRef, cardNumberRef, frozenIconRef, buttonRef) = createRefs() + + if (displayNameState != null) { + CardDisplayName( + state = displayNameState, + modifier = Modifier.constrainAs(displayNameRef) { + start.linkTo(parent.start) + bottom.linkTo(cardNumberRef.top) + width = Dimension.wrapContent + }, + ) + } + Text( text = shortCardNumber, style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.constantWhite, + modifier = Modifier + .constrainAs(cardNumberRef) { + start.linkTo(parent.start) + bottom.linkTo(parent.bottom) + } + .padding(bottom = 8.dp), ) when (cardFrozenState) { is TangemPayCardFrozenState.Frozen -> Icon( modifier = Modifier - .padding(start = 4.dp) + .constrainAs(frozenIconRef) { + start.linkTo(cardNumberRef.end, margin = 4.dp) + top.linkTo(cardNumberRef.top) + bottom.linkTo(cardNumberRef.bottom) + } + .padding(bottom = 8.dp) .size(16.dp), painter = painterResource(id = R.drawable.ic_snow_24), contentDescription = null, @@ -138,16 +191,24 @@ private fun TangemPayCardDetailsHiddenBlock( ) TangemPayCardFrozenState.Pending -> CircularProgressIndicator( modifier = Modifier - .padding(start = 4.dp) + .constrainAs(frozenIconRef) { + start.linkTo(cardNumberRef.end, margin = 4.dp) + top.linkTo(cardNumberRef.top) + bottom.linkTo(cardNumberRef.bottom) + } + .padding(bottom = 8.dp) .size(16.dp), color = TangemTheme.colors.text.constantWhite, strokeWidth = 1.dp, ) TangemPayCardFrozenState.Unfrozen -> Unit } - SpacerWMax() + TangemPayCardDetailsCustomButton( - modifier = Modifier.padding(bottom = 8.dp), + modifier = Modifier.constrainAs(buttonRef) { + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + }, text = stringResourceSafe(id = R.string.tangempay_card_details_show_details), onClick = onShowDetails, showProgress = isLoading, @@ -156,6 +217,97 @@ private fun TangemPayCardDetailsHiddenBlock( } } +@Composable +private fun CardDisplayName(state: DisplayNameState, modifier: Modifier = Modifier) { + val isDisplayMode = state is DisplayNameState.Display + + Row( + modifier = modifier.then( + if (state is DisplayNameState.Display) { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = state.onClick, + ) + } else { + Modifier + }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + when (state) { + is DisplayNameState.Display -> DisplayOnlyCardDisplayName(state = state) + is DisplayNameState.Editing -> EditingCardDisplayName(state = state) + } + val iconVisibleState = remember { + MutableTransitionState(initialState = !isDisplayMode).apply { + targetState = isDisplayMode + } + } + AnimatedVisibility( + visibleState = iconVisibleState, + enter = fadeIn(animationSpec = tween(durationMillis = ICON_FADE_DURATION_MS)), + exit = fadeOut(animationSpec = tween(durationMillis = ICON_FADE_DURATION_MS)), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.width(6.dp)) + Icon( + painter = painterResource(id = com.tangem.core.ui.R.drawable.ic_edit_new_12), + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = TangemTheme.colors.text.constantWhite, + ) + } + } + } +} + +@Composable +private fun DisplayOnlyCardDisplayName(state: DisplayNameState.Display, modifier: Modifier = Modifier) { + Text( + text = state.displayName, + style = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite), + maxLines = 1, + modifier = modifier, + ) +} + +@Composable +private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Modifier = Modifier) { + val focusRequester = remember { FocusRequester() } + var textFieldValue by remember(state.editingValue) { + mutableStateOf( + TextFieldValue(text = state.editingValue, selection = TextRange(state.editingValue.length)), + ) + } + + val textStyle = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite) + val textMeasurer = rememberTextMeasurer() + val textWidthDp = with(LocalDensity.current) { + textMeasurer.measure(textFieldValue.text, textStyle).size.width.toDp() + 2.dp + } + + BasicTextField( + value = textFieldValue, + onValueChange = { newValue -> + if (newValue.text.length in 0..CardDisplayName.MAX_LENGTH) { + textFieldValue = newValue + state.onValueChanged(newValue.text) + } + }, + modifier = modifier + .width(textWidthDp.coerceAtLeast(1.dp)) + .focusRequester(focusRequester), + textStyle = textStyle, + singleLine = true, + cursorBrush = SolidColor(TangemTheme.colors.text.constantWhite), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { state.onSubmit() }), + ) + + LaunchedEffect(Unit) { focusRequester.requestFocus() } +} + @Suppress("MagicNumber", "LongParameterList") @Composable private fun TangemPayCardDetailsShownBlock( @@ -313,6 +465,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Frozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem", onClick = {}), ), TangemPayCardDetailsUM( isLoading = false, @@ -325,6 +478,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), TangemPayCardDetailsUM( isLoading = false, @@ -337,6 +491,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Pending, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), TangemPayCardDetailsUM( isLoading = false, @@ -349,6 +504,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "12/34", cvv = "123", cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), ), ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 20a8d78463..2dc2540204 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -1,6 +1,11 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -20,6 +25,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity @@ -33,11 +39,20 @@ import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.entity.TangemPayCardPageSetting import com.tangem.features.tangempay.entity.TangemPayCardPageUM import kotlinx.collections.immutable.ImmutableList +private const val CONTENT_FADE_DURATION_MS = 300 +private val TangemPayCardPageSetting.titleRes + get() = when (this) { + TangemPayCardPageSetting.ChangePIN -> R.string.tangempay_card_details_change_pin + TangemPayCardPageSetting.FreezeCard -> R.string.tangempay_card_details_freeze_card + TangemPayCardPageSetting.ReplaceCard -> R.string.common_error // TODO v_rodionov #[REDACTED_TASK_KEY] + } + @Composable internal fun TangemPayCardPageScreen( state: TangemPayCardPageUM, @@ -76,16 +91,30 @@ internal fun TangemPayCardPageScreen( } if (state.addToWalletBlockState != null) { item(key = "GooglePay") { - TangemPayAddToWalletBlock( - state = state.addToWalletBlockState, - ) + val visibleState = remember { MutableTransitionState(false).apply { targetState = true } } + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)), + exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)), + ) { + TangemPayAddToWalletBlock( + state = state.addToWalletBlockState, + ) + } } } item(key = "Settings") { - TangemPayCardPageSettingsBlock( - settings = state.settings, - onSettingClick = state.onSettingClick, - ) + val visibleState = remember { MutableTransitionState(false).apply { targetState = true } } + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)), + exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)), + ) { + TangemPayCardPageSettingsBlock( + settings = state.settings, + onSettingClick = state.onSettingClick, + ) + } } } } @@ -145,13 +174,6 @@ private fun TangemPayCardPageSettingRow( } } -private val TangemPayCardPageSetting.titleRes - get() = when (this) { - TangemPayCardPageSetting.ChangePIN -> R.string.tangempay_card_details_change_pin - TangemPayCardPageSetting.FreezeCard -> R.string.tangempay_card_details_freeze_card - TangemPayCardPageSetting.ReplaceCard -> R.string.common_error // TODO v_rodionov #[REDACTED_TASK_KEY] - } - @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -167,6 +189,7 @@ private fun preview() = TangemThemePreview { onCopy = { _, _ -> }, onClick = {}, cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), ), cardDetailsState = TangemPayCardDetailsUM( @@ -177,6 +200,7 @@ private fun preview() = TangemThemePreview { onCopy = { _, _ -> }, onClick = {}, cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), ), ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 1e3a51c9a4..86e06e7ffe 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -312,6 +312,7 @@ private fun TangemPayDetailsScreenPreview( onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = null, ), ), expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), @@ -391,6 +392,7 @@ private fun TangemPayDetailsTxHistoryScreenPreview( onClick = {}, buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = null, ), ), expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt new file mode 100644 index 0000000000..ffe3a645b9 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt @@ -0,0 +1,82 @@ +package com.tangem.features.tangempay.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.navigationButtons.NavigationButton +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM + +@Composable +internal fun TangemPayEditDisplayNameScreen( + state: TangemPayEditDisplayNameUM, + cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, + cardDetailsState: TangemPayCardDetailsUM, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.secondary) + .navigationBarsPadding(), + ) { + Box( + modifier = Modifier + .statusBarsPadding() + .height(56.dp) + .fillMaxWidth(), + ) { + IconButton( + modifier = Modifier.padding(start = 4.dp, top = 4.dp), + onClick = state.onDismiss, + ) { + Icon( + painter = painterResource(id = com.tangem.core.ui.R.drawable.ic_close_24), + contentDescription = null, + tint = TangemTheme.colors.icon.primary1, + ) + } + } + + cardDetailsBlockComponent.CardDetailsBlockContent( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 8.dp), + state = cardDetailsState, + ) + + Spacer(modifier = Modifier.weight(1f)) + + NavigationPrimaryButton( + modifier = Modifier + .imePadding() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + .fillMaxWidth(), + primaryButton = NavigationButton( + textReference = resourceReference(R.string.common_done), + onClick = state.onDoneClick, + shouldShowProgress = state.isLoading, + isEnabled = !state.isLoading && state.editingValue.isNotBlank(), + ), + ) + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt index 3e1615a6e2..238c8a36f1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt @@ -80,6 +80,7 @@ internal class TangemPayUpdateInfoStateTransformer( cardFrozenState = cardFrozenState, cardNumberEnd = cardInfo.lastFourDigits, chainId = POLYGON_CHAIN_ID, + displayName = productInstance.displayName, ), ) }, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index 1ce25e26ac..a065f12000 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -74,6 +74,7 @@ internal class TangemPayMainBlockConverter( cardFrozenState = TangemPayCardFrozenState.Frozen, cardNumberEnd = statusValue.lastFourDigits, chainId = POLYGON_CHAIN_ID, + displayName = statusValue.displayName, ), ) }, @@ -97,6 +98,7 @@ internal class TangemPayMainBlockConverter( cardFrozenState = TangemPayCardFrozenState.Unfrozen, cardNumberEnd = statusValue.lastFourDigits, chainId = POLYGON_CHAIN_ID, + displayName = statusValue.displayName, ), ) }, From 5f2acac13785bb0a7631effa891ae182203fee7f Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 31 Mar 2026 14:31:37 +0400 Subject: [PATCH 020/206] Updated on 2026-08-14 --- .../com/tangem/tap/common/redux/AppReducer.kt | 2 - .../com/tangem/tap/common/redux/AppState.kt | 4 - .../features/details/redux/DetailsAction.kt | 47 --- .../details/redux/DetailsMiddleware.kt | 231 ------------- .../features/details/redux/DetailsReducer.kt | 89 ----- .../features/details/redux/DetailsState.kt | 27 -- .../features/details/redux/SecurityOption.kt | 3 + .../ui/appsettings/model/AppSettingsModel.kt | 304 ++++++++++++------ 8 files changed, 214 insertions(+), 493 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt create mode 100644 app/src/main/java/com/tangem/tap/features/details/redux/SecurityOption.kt diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt index bc16aae955..092abd503b 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt @@ -1,7 +1,6 @@ package com.tangem.tap.common.redux import com.tangem.tap.common.redux.global.globalReducer -import com.tangem.tap.features.details.redux.DetailsReducer import com.tangem.tap.proxy.redux.DaggerGraphReducer import org.rekotlin.Action @@ -10,7 +9,6 @@ fun appReducer(action: Action, state: AppState): AppState { return AppState( globalState = globalReducer(action, state), - detailsState = DetailsReducer.reduce(action, state), daggerGraphState = DaggerGraphReducer.reduce(action, state), ) } diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt index a6275955a1..d854ba1088 100644 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt @@ -1,8 +1,6 @@ package com.tangem.tap.common.redux import com.tangem.tap.common.redux.global.GlobalState -import com.tangem.tap.features.details.redux.DetailsMiddleware -import com.tangem.tap.features.details.redux.DetailsState import com.tangem.tap.proxy.redux.DaggerGraphMiddleware import com.tangem.tap.proxy.redux.DaggerGraphState import org.rekotlin.Middleware @@ -10,7 +8,6 @@ import org.rekotlin.StateType data class AppState( val globalState: GlobalState = GlobalState(), - val detailsState: DetailsState = DetailsState(), val daggerGraphState: DaggerGraphState = DaggerGraphState(), ) : StateType { @@ -18,7 +15,6 @@ data class AppState( fun getMiddleware(): List> { return listOf( logMiddleware, - DetailsMiddleware().detailsMiddleware, LockUserWalletsTimerMiddleware().middleware, AccessCodeRequestPolicyMiddleware().middleware, DaggerGraphMiddleware.daggerGraphMiddleware, diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt deleted file mode 100644 index 072d738717..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsAction.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.tangem.tap.features.details.redux - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.apptheme.model.AppThemeMode -import kotlinx.coroutines.CoroutineScope -import org.rekotlin.Action - -@Suppress("BooleanPropertyNaming") -sealed class DetailsAction : Action { - - sealed class AppSettings : DetailsAction() { - data class SwitchPrivacySetting( - val enable: Boolean, - val setting: AppSetting, - ) : AppSettings() { - data object Success : AppSettings() - - data class Failure( - val prevState: Boolean, - val setting: AppSetting, - ) : AppSettings() - } - - data class CheckBiometricsStatus( - val coroutineScope: CoroutineScope, - ) : AppSettings() - - data object EnrollBiometrics : AppSettings() - data class BiometricsStatusChanged( - val isEnrollBiometricsNeeded: Boolean, - ) : AppSettings() - - data class ChangeAppThemeMode( - val appThemeMode: AppThemeMode, - ) : AppSettings() - - data class ChangeBalanceHiding( - val shouldHideBalance: Boolean, - ) : AppSettings() - - data class ChangeAppCurrency( - val currency: AppCurrency, - ) : AppSettings() - - data class Prepare(val state: AppSettingsState) : AppSettings() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt deleted file mode 100644 index fa438bf186..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsMiddleware.kt +++ /dev/null @@ -1,231 +0,0 @@ -package com.tangem.tap.features.details.redux - -import com.tangem.common.CompletionResult -import com.tangem.common.doOnFailure -import com.tangem.common.doOnSuccess -import com.tangem.core.analytics.Analytics -import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.tap.common.analytics.events.AnalyticsParam -import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.features.demo.DemoHelper -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import com.tangem.tap.tangemSdkManager -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn -import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Middleware - -@Suppress("MemberNameEqualsClassName") -class DetailsMiddleware { - private val appSettingsMiddleware = AppSettingsMiddleware() - val detailsMiddleware: Middleware = { _, stateProvider -> - { next -> - { action -> - if (!DemoHelper.tryHandle(stateProvider)) { - val detailsState = stateProvider()?.detailsState - if (detailsState != null) { - handleAction(action) - } - } - next(action) - } - } - } - - private fun handleAction(action: Action) { - when (action) { - is DetailsAction.AppSettings -> appSettingsMiddleware.handle(action) - } - } - - class AppSettingsMiddleware { - - private val checkBiometricsStatusJobHolder = JobHolder() - - fun handle(action: DetailsAction.AppSettings) { - when (action) { - is DetailsAction.AppSettings.SwitchPrivacySetting -> { - when (action.setting) { - AppSetting.RequireAccessCode -> toggleRequireAccessCode(enable = action.enable) - AppSetting.BiometricAuthentication -> toggleBiometricsAuthentication(enable = action.enable) - } - } - is DetailsAction.AppSettings.CheckBiometricsStatus -> { - observeBiometricsStatusChanges(action.coroutineScope) - } - is DetailsAction.AppSettings.EnrollBiometrics -> { - enrollBiometrics() - } - is DetailsAction.AppSettings.ChangeAppThemeMode -> { - changeAppThemeMode(action.appThemeMode) - } - is DetailsAction.AppSettings.ChangeBalanceHiding -> { - changeBalanceHiding(action.shouldHideBalance) - } - is DetailsAction.AppSettings.ChangeAppCurrency, - is DetailsAction.AppSettings.SwitchPrivacySetting.Success, - is DetailsAction.AppSettings.SwitchPrivacySetting.Failure, - is DetailsAction.AppSettings.BiometricsStatusChanged, - is DetailsAction.AppSettings.Prepare, - -> Unit - } - } - - private fun toggleBiometricsAuthentication(enable: Boolean) { - scope.launch { - val walletsRepository = store.inject(DaggerGraphState::walletsRepository) - - // Nothing to change - if (walletsRepository.useBiometricAuthentication() == enable) { - store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success) - return@launch - } - - if (enable) { - setBiometricLockForAllWallets() - } else { - // Remove all biometric-related data - removeAllBiometricData() - walletsRepository.setRequireAccessCode(value = true) - } - - walletsRepository.setUseBiometricAuthentication(value = enable) - store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success) - } - } - - private fun toggleRequireAccessCode(enable: Boolean) { - scope.launch { - val walletsRepository = store.inject(DaggerGraphState::walletsRepository) - - // Nothing to change - if (walletsRepository.requireAccessCode() == enable) { - store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success) - return@launch - } - - if (enable) { - // Remove all saved access codes - removeAllBiometricSingData() - } - - walletsRepository.setRequireAccessCode(value = enable) - store.dispatchWithMain(DetailsAction.AppSettings.SwitchPrivacySetting.Success) - } - } - - private suspend fun setBiometricLockForAllWallets() { - val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) - val userWallets = userWalletsListRepository.userWalletsSync() - userWallets.forEach { wallet -> - userWalletsListRepository.setLock( - userWalletId = wallet.walletId, - lockMethod = LockMethod.Biometric, - changeUnsecured = false, - ) - } - } - - private suspend fun removeAllBiometricData() { - val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) - userWalletsListRepository.userWalletsSync().forEach { - userWalletsListRepository.removeBiometricLock(it.walletId) - } - removeAllBiometricSingData() - } - - private suspend fun removeAllBiometricSingData() { - deleteSavedAccessCodes() - val userWalletsListRepository = store.inject(DaggerGraphState::userWalletsListRepository) - val tangemHotSdk = store.inject(DaggerGraphState::tangemHotSdk) - userWalletsListRepository.userWalletsSync().forEach { wallet -> - if (wallet is UserWallet.Hot) { - userWalletsListRepository.saveWithoutLock( - userWallet = wallet.copy( - hotWalletId = tangemHotSdk.removeBiometryAuthIfPresented(wallet.hotWalletId), - ), - ) - } - } - } - - private fun observeBiometricsStatusChanges(scope: CoroutineScope) { - val needEnrollBiometricsFlow = flow { - do { - val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() - - if (isEnrollBiometricsNeeded != null) { - emit(isEnrollBiometricsNeeded) - } - - delay(timeMillis = 200) - } while (true) - } - - needEnrollBiometricsFlow - .distinctUntilChanged() - .onEach { needEnrollBiometrics -> - store.dispatchWithMain(DetailsAction.AppSettings.BiometricsStatusChanged(needEnrollBiometrics)) - } - .launchIn(scope) - .saveIn(checkBiometricsStatusJobHolder) - } - - private fun enrollBiometrics() { - Analytics.send(Settings.AppSettings.ButtonEnableBiometricAuthentication()) - store.inject(DaggerGraphState::settingsManager).openBiometricSettings() - } - - private fun changeAppThemeMode(appThemeMode: AppThemeMode) { - val repository = store.inject(DaggerGraphState::appThemeModeRepository) - - scope.launch { - repository.changeAppThemeMode(appThemeMode) - } - } - - private fun changeBalanceHiding(hideBalance: Boolean) { - val repository = store.inject(DaggerGraphState::balanceHidingRepository) - - scope.launch { - val newState = repository.getBalanceHidingSettings().copy( - isHidingEnabledInSettings = hideBalance, - isBalanceHidden = false, - ) - - repository.storeBalanceHidingSettings(newState) - } - } - - private suspend fun deleteSavedAccessCodes(): CompletionResult { - return tangemSdkManager.clearSavedUserCodes() - .doOnSuccess { - Analytics.send(Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.Off)) - - store.inject(DaggerGraphState::settingsRepository).setShouldSaveAccessCodes(value = false) - - store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( - isBiometricsRequestPolicy = false, - ) - } - .doOnFailure { error -> - TangemLogger.e("Unable to delete saved access codes", error) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt deleted file mode 100644 index 40549d69f4..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsReducer.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.tangem.tap.features.details.redux - -import com.tangem.tap.common.redux.AppState -import org.rekotlin.Action - -object DetailsReducer { - fun reduce(action: Action, state: AppState): DetailsState = internalReduce(action, state) -} - -@Suppress("CyclomaticComplexMethod") -private fun internalReduce(action: Action, state: AppState): DetailsState { - if (action !is DetailsAction) return state.detailsState - val detailsState = state.detailsState - return when (action) { - is DetailsAction.AppSettings -> { - handlePrivacyAction(action, detailsState) - } - } -} - -@Suppress("LongMethod", "CyclomaticComplexMethod") -private fun handlePrivacyAction(action: DetailsAction.AppSettings, state: DetailsState): DetailsState { - return when (action) { - is DetailsAction.AppSettings.SwitchPrivacySetting -> state.copy( - appSettingsState = when (action.setting) { - AppSetting.RequireAccessCode -> state.appSettingsState.copy( - isInProgress = true, - requireAccessCode = action.enable, - ) - AppSetting.BiometricAuthentication -> state.appSettingsState.copy( - isInProgress = true, - useBiometricAuthentication = action.enable, - ) - }, - ) - is DetailsAction.AppSettings.SwitchPrivacySetting.Success -> state.copy( - appSettingsState = state.appSettingsState.copy( - isInProgress = false, - ), - ) - is DetailsAction.AppSettings.SwitchPrivacySetting.Failure -> state.copy( - appSettingsState = when (action.setting) { - AppSetting.RequireAccessCode -> state.appSettingsState.copy( - isInProgress = false, - requireAccessCode = action.prevState, - ) - AppSetting.BiometricAuthentication -> state.appSettingsState.copy( - isInProgress = false, - needEnrollBiometrics = action.prevState, - ) - }, - ) - is DetailsAction.AppSettings.BiometricsStatusChanged -> state.copy( - appSettingsState = state.appSettingsState.copy( - needEnrollBiometrics = action.isEnrollBiometricsNeeded, - ), - ) - is DetailsAction.AppSettings.ChangeAppThemeMode -> state.copy( - appSettingsState = state.appSettingsState.copy( - selectedThemeMode = action.appThemeMode, - ), - ) - is DetailsAction.AppSettings.ChangeAppCurrency -> state.copy( - appSettingsState = state.appSettingsState.copy( - selectedAppCurrency = action.currency, - ), - ) - is DetailsAction.AppSettings.ChangeBalanceHiding -> state.copy( - appSettingsState = state.appSettingsState.copy( - isHidingEnabled = action.shouldHideBalance, - ), - ) - // state should be copied to avoid concurrent modifications from different sources - is DetailsAction.AppSettings.Prepare -> state.copy( - appSettingsState = state.appSettingsState.copy( - isHidingEnabled = action.state.isHidingEnabled, - selectedAppCurrency = action.state.selectedAppCurrency, - selectedThemeMode = action.state.selectedThemeMode, - useBiometricAuthentication = action.state.useBiometricAuthentication, - requireAccessCode = action.state.requireAccessCode, - hasSecuredWallets = action.state.hasSecuredWallets, - needEnrollBiometrics = action.state.needEnrollBiometrics, - ), - ) - is DetailsAction.AppSettings.EnrollBiometrics, - is DetailsAction.AppSettings.CheckBiometricsStatus, - -> state - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt b/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt deleted file mode 100644 index e209c707e4..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/redux/DetailsState.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tangem.tap.features.details.redux - -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.apptheme.model.AppThemeMode -import org.rekotlin.StateType - -data class DetailsState( - val appSettingsState: AppSettingsState = AppSettingsState(), -) : StateType - -@Suppress("BooleanPropertyNaming") -data class AppSettingsState( - val requireAccessCode: Boolean = false, - val useBiometricAuthentication: Boolean = false, - val needEnrollBiometrics: Boolean = false, - val hasSecuredWallets: Boolean = false, - val isHidingEnabled: Boolean = false, - val isInProgress: Boolean = false, - val selectedAppCurrency: AppCurrency = AppCurrency.Default, - val selectedThemeMode: AppThemeMode = AppThemeMode.DEFAULT, -) - -enum class SecurityOption { LongTap, PassCode, AccessCode } - -enum class AppSetting { - RequireAccessCode, BiometricAuthentication, -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/redux/SecurityOption.kt b/app/src/main/java/com/tangem/tap/features/details/redux/SecurityOption.kt new file mode 100644 index 0000000000..e55259df3b --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/details/redux/SecurityOption.kt @@ -0,0 +1,3 @@ +package com.tangem.tap.features.details.redux + +enum class SecurityOption { LongTap, PassCode, AccessCode } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt index e65c6560da..c9edf25848 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt @@ -1,11 +1,15 @@ package com.tangem.tap.features.details.ui.appsettings.model import androidx.compose.runtime.Stable +import com.tangem.common.doOnFailure +import com.tangem.common.doOnSuccess import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.settings.SettingsManager import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.appcurrency.model.AppCurrency @@ -13,42 +17,38 @@ import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.apptheme.repository.AppThemeModeRepository import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository +import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.sdk.api.TangemSdkManager +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.hot.sdk.TangemHotSdk +import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.dispatchOnMain -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.features.details.redux.AppSetting -import com.tangem.tap.features.details.redux.AppSettingsState -import com.tangem.tap.features.details.redux.DetailsAction -import com.tangem.tap.features.details.redux.DetailsState import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory import com.tangem.tap.features.details.ui.appsettings.AppSettingsItemsFactory import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState import com.tangem.tap.features.details.ui.appsettings.analytics.AppSettingsItemsAnalyticsSender -import com.tangem.tap.scope -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import com.tangem.utils.extensions.addIf +import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import org.rekotlin.StoreSubscriber import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class AppSettingsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val appCurrencyRepository: AppCurrencyRepository, + appCurrencyRepository: AppCurrencyRepository, private val walletsRepository: WalletsRepository, private val userWalletsListRepository: UserWalletsListRepository, private val balanceHidingRepository: BalanceHidingRepository, @@ -56,72 +56,100 @@ internal class AppSettingsModel @Inject constructor( private val appThemeModeRepository: AppThemeModeRepository, private val appSettingsItemsAnalyticsSender: AppSettingsItemsAnalyticsSender, private val tangemSdkManager: TangemSdkManager, + private val settingsManager: SettingsManager, + private val settingsRepository: SettingsRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val tangemHotSdk: TangemHotSdk, + private val router: Router, private val uiMessageSender: UiMessageSender, -) : Model(), StoreSubscriber { +) : Model() { private val itemsFactory = AppSettingsItemsFactory() private val dialogsFactory = AppSettingsDialogsFactory() - private val appCurrencyUpdatesJobHolder = JobHolder() + private val localState = MutableStateFlow(LocalState()) + private val biometricsStatusJobHolder = JobHolder() - private val _uiState: MutableStateFlow = MutableStateFlow( - value = AppSettingsScreenState.Loading, - ) - val uiState: StateFlow = _uiState + val uiState: StateFlow + field = MutableStateFlow(value = AppSettingsScreenState.Loading) init { - bootstrapAppCurrencyUpdates() - bootstrapBiometricsUpdates() + bootstrapLocalState() + + combine( + flow = appCurrencyRepository.getSelectedAppCurrency().distinctUntilChanged(), + flow2 = appThemeModeRepository.getAppThemeMode(), + flow3 = balanceHidingRepository.getBalanceHidingSettingsFlow(), + flow4 = localState, + ) { currency, themeMode, hidingSettings, local -> + AppSettingsState( + appCurrency = currency, + themeMode = themeMode, + isHidingEnabled = hidingSettings.isHidingEnabledInSettings, + local = local, + ) + } + .onEach { state -> + val items = buildItems(state) + uiState.update { prevState -> + when (prevState) { + is AppSettingsScreenState.Content -> prevState.copy(items = items) + is AppSettingsScreenState.Loading -> AppSettingsScreenState.Content( + items = items, + dialog = null, + ) + } + } + } + .launchIn(modelScope) - subscribeToStoreChanges() sendItemsAnalytics() } - override fun newState(state: DetailsState) { - val items = buildItems(state.appSettingsState) - - _uiState.update { prevState -> - when (prevState) { - is AppSettingsScreenState.Content -> prevState.copy( - items = items, - ) - is AppSettingsScreenState.Loading -> AppSettingsScreenState.Content( - items = items, - dialog = null, - ) - } - } - } - fun onResume() { - store.dispatch(DetailsAction.AppSettings.CheckBiometricsStatus(modelScope)) + observeBiometricsStatusChanges() } - override fun onDestroy() { - super.onDestroy() - store.unsubscribe(subscriber = this) + private fun observeBiometricsStatusChanges() { + flow { + do { + val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() + if (isEnrollBiometricsNeeded != null) { + emit(isEnrollBiometricsNeeded) + } + delay(timeMillis = 200) + } while (true) + } + .flowOn(dispatchers.default) + .distinctUntilChanged() + .onEach { isEnrollBiometricsNeeded -> + localState.update { it.copy(isEnrollBiometricsNeeded = isEnrollBiometricsNeeded) } + } + .launchIn(modelScope) + .saveIn(biometricsStatusJobHolder) } private fun buildItems(state: AppSettingsState): ImmutableList { val items = buildList { addIf( - condition = state.needEnrollBiometrics, + condition = state.local.isEnrollBiometricsNeeded, element = itemsFactory.createEnrollBiometricsCard(onClick = ::enrollBiometrics), ) add( itemsFactory.createSelectAppCurrencyButton( - currentAppCurrencyName = state.selectedAppCurrency.name, + currentAppCurrencyName = state.appCurrency.name, onClick = ::showAppCurrencySelector, ), ) - val canUseBiometrics = - !state.needEnrollBiometrics && !state.isInProgress && state.hasSecuredWallets + val canUseBiometrics = with(state.local) { + !isEnrollBiometricsNeeded && !isInProgress && hasSecuredWallets + } add( itemsFactory.createUseBiometricsSwitch( - isChecked = state.useBiometricAuthentication, + isChecked = state.local.isBiometricAuthenticationUsed, isEnabled = canUseBiometrics, onCheckedChange = ::onBiometricAuthenticationToggled, onDisabledClick = ::onBiometricAuthenticationDisabledClicked, @@ -130,8 +158,8 @@ internal class AppSettingsModel @Inject constructor( add( itemsFactory.createRequireAccessCodeSwitch( - isChecked = state.requireAccessCode || !state.useBiometricAuthentication, - isEnabled = canUseBiometrics && state.useBiometricAuthentication, + isChecked = state.local.isAccessCodeRequired || !state.local.isBiometricAuthenticationUsed, + isEnabled = canUseBiometrics && state.local.isBiometricAuthenticationUsed, onCheckedChange = ::onRequireAccessCodeToggled, ), ) @@ -146,8 +174,8 @@ internal class AppSettingsModel @Inject constructor( add( itemsFactory.createSelectThemeModeButton( - currentThemeMode = state.selectedThemeMode, - onClick = { showThemeModeSelector(state.selectedThemeMode) }, + currentThemeMode = state.themeMode, + onClick = { showThemeModeSelector(state.themeMode) }, ), ) } @@ -156,11 +184,12 @@ internal class AppSettingsModel @Inject constructor( } private fun enrollBiometrics() { - store.dispatchOnMain(DetailsAction.AppSettings.EnrollBiometrics) + analyticsEventHandler.send(Settings.AppSettings.ButtonEnableBiometricAuthentication()) + settingsManager.openBiometricSettings() } private fun showAppCurrencySelector() { - store.dispatchNavigationAction { push(AppRoute.AppCurrencySelector) } + router.push(AppRoute.AppCurrencySelector) } private fun showThemeModeSelector(selectedMode: AppThemeMode) { @@ -174,7 +203,7 @@ internal class AppSettingsModel @Inject constructor( theme = AnalyticsParam.AppTheme.fromAppThemeMode(mode), ), ) - store.dispatchOnMain(DetailsAction.AppSettings.ChangeAppThemeMode(mode)) + changeAppThemeMode(mode) dismissDialog() }, onDismiss = ::dismissDialog, @@ -188,13 +217,13 @@ internal class AppSettingsModel @Inject constructor( // val param = AnalyticsParam.OnOffState(isChecked) // analyticsEventHandler.send(Settings.AppSettings.BiometricAuthenticationChanged(param)) if (isChecked) { - onSettingsToggled(AppSetting.BiometricAuthentication, enable = true) + toggleBiometricsAuthentication(enable = true) } else { updateContentState { copy( dialog = dialogsFactory.createDisableBiometricAuthenticationAlert( onDisable = { - onSettingsToggled(AppSetting.BiometricAuthentication, enable = false) + toggleBiometricsAuthentication(enable = false) dismissDialog() }, onDismiss = ::dismissDialog, @@ -217,7 +246,7 @@ internal class AppSettingsModel @Inject constructor( dialog = if (isChecked) { dialogsFactory.createEnableRequireAccessCodeAlert( onEnable = { - onSettingsToggled(AppSetting.RequireAccessCode, enable = true) + toggleRequireAccessCode(enable = true) dismissDialog() }, onDismiss = ::dismissDialog, @@ -225,7 +254,7 @@ internal class AppSettingsModel @Inject constructor( } else { dialogsFactory.createDisableRequireAccessCodeAlert( onDisable = { - onSettingsToggled(AppSetting.RequireAccessCode, enable = false) + toggleRequireAccessCode(enable = false) dismissDialog() }, onDismiss = ::dismissDialog, @@ -235,51 +264,125 @@ internal class AppSettingsModel @Inject constructor( } } - private fun onSettingsToggled(setting: AppSetting, enable: Boolean) { - store.dispatch(DetailsAction.AppSettings.SwitchPrivacySetting(enable = enable, setting = setting)) + private fun toggleBiometricsAuthentication(enable: Boolean) { + localState.update { it.copy(isBiometricAuthenticationUsed = enable, isInProgress = true) } + + modelScope.launch { + // Nothing to change + if (walletsRepository.useBiometricAuthentication() == enable) { + localState.update { it.copy(isInProgress = false) } + return@launch + } + + if (enable) { + setBiometricLockForAllWallets() + } else { + removeAllBiometricData() + walletsRepository.setRequireAccessCode(value = true) + localState.update { it.copy(isAccessCodeRequired = true) } + } + + walletsRepository.setUseBiometricAuthentication(value = enable) + localState.update { it.copy(isInProgress = false) } + } + } + + private fun toggleRequireAccessCode(enable: Boolean) { + localState.update { it.copy(isAccessCodeRequired = enable, isInProgress = true) } + + modelScope.launch { + // Nothing to change + if (walletsRepository.requireAccessCode() == enable) { + localState.update { it.copy(isInProgress = false) } + return@launch + } + + if (enable) { + removeAllBiometricSingData(userWalletsListRepository.userWalletsSync()) + } + + walletsRepository.setRequireAccessCode(value = enable) + localState.update { it.copy(isInProgress = false) } + } + } + + private suspend fun setBiometricLockForAllWallets() { + val userWallets = userWalletsListRepository.userWalletsSync() + userWallets.forEach { wallet -> + userWalletsListRepository.setLock( + userWalletId = wallet.walletId, + lockMethod = UserWalletsListRepository.LockMethod.Biometric, + changeUnsecured = false, + ) + } + } + + private suspend fun removeAllBiometricData() { + val userWallets = userWalletsListRepository.userWalletsSync() + userWallets.forEach { + userWalletsListRepository.removeBiometricLock(it.walletId) + } + removeAllBiometricSingData(userWallets) + } + + private suspend fun removeAllBiometricSingData(userWallets: List) { + deleteSavedAccessCodes() + userWallets.forEach { wallet -> + if (wallet is UserWallet.Hot) { + userWalletsListRepository.saveWithoutLock( + userWallet = wallet.copy( + hotWalletId = tangemHotSdk.removeBiometryAuthIfPresented(wallet.hotWalletId), + ), + ) + } + } + } + + private suspend fun deleteSavedAccessCodes() { + tangemSdkManager.clearSavedUserCodes() + .doOnSuccess { + analyticsEventHandler.send( + Settings.AppSettings.SaveAccessCodeSwitcherChanged(AnalyticsParam.OnOffState.Off), + ) + settingsRepository.setShouldSaveAccessCodes(value = false) + cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false) + } + .doOnFailure { error -> + TangemLogger.e("Unable to delete saved access codes", error) + } } private fun onFlipToHideBalanceToggled(enable: Boolean) { val param = AnalyticsParam.OnOffState(enable) analyticsEventHandler.send(Settings.AppSettings.HideBalanceChanged(param)) - store.dispatch(DetailsAction.AppSettings.ChangeBalanceHiding(shouldHideBalance = enable)) + modelScope.launch { + val settings = balanceHidingRepository.getBalanceHidingSettings().copy( + isHidingEnabledInSettings = enable, + isBalanceHidden = false, + ) + balanceHidingRepository.storeBalanceHidingSettings(settings) + } + } + + private fun changeAppThemeMode(mode: AppThemeMode) { + modelScope.launch { + appThemeModeRepository.changeAppThemeMode(mode) + } } private fun dismissDialog() { updateContentState { copy(dialog = null) } } - private fun bootstrapAppCurrencyUpdates() { - appCurrencyRepository - .getSelectedAppCurrency() - .distinctUntilChanged() - .onEach { appCurrency -> - store.dispatchWithMain(DetailsAction.AppSettings.ChangeAppCurrency(appCurrency)) - } - .launchIn(scope) - .saveIn(appCurrencyUpdatesJobHolder) - } - - private fun bootstrapBiometricsUpdates() = modelScope.launch { - val state = AppSettingsState( - useBiometricAuthentication = walletsRepository.useBiometricAuthentication(), - requireAccessCode = walletsRepository.requireAccessCode(), - isHidingEnabled = balanceHidingRepository.getBalanceHidingSettings().isHidingEnabledInSettings, - selectedAppCurrency = appCurrencyRepository.getSelectedAppCurrency().firstOrNull() ?: AppCurrency.Default, - selectedThemeMode = appThemeModeRepository.getAppThemeMode().firstOrNull() ?: AppThemeMode.DEFAULT, - needEnrollBiometrics = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, - hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(), - ) - - store.dispatchWithMain(DetailsAction.AppSettings.Prepare(state)) - } - - private fun subscribeToStoreChanges() { - store.subscribe(subscriber = this) { state -> - state.skipRepeats { oldState, newState -> - oldState.detailsState == newState.detailsState - }.select { it.detailsState } + private fun bootstrapLocalState() = modelScope.launch { + localState.update { state -> + state.copy( + hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(), + isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, + isBiometricAuthenticationUsed = walletsRepository.useBiometricAuthentication(), + isAccessCodeRequired = walletsRepository.requireAccessCode(), + ) } } @@ -288,15 +391,30 @@ internal class AppSettingsModel @Inject constructor( .filterIsInstance() .distinctUntilChangedBy(AppSettingsScreenState.Content::items) .onEach { appSettingsItemsAnalyticsSender.send(it.items) } - .launchIn(scope) + .launchIn(modelScope) } private fun updateContentState(block: AppSettingsScreenState.Content.() -> AppSettingsScreenState.Content) { - _uiState.update { prevState -> + uiState.update { prevState -> when (prevState) { is AppSettingsScreenState.Content -> block(prevState) is AppSettingsScreenState.Loading -> prevState } } } + + private data class LocalState( + val hasSecuredWallets: Boolean = false, + val isEnrollBiometricsNeeded: Boolean = false, + val isBiometricAuthenticationUsed: Boolean = false, + val isAccessCodeRequired: Boolean = false, + val isInProgress: Boolean = false, + ) + + private data class AppSettingsState( + val themeMode: AppThemeMode = AppThemeMode.DEFAULT, + val isHidingEnabled: Boolean = false, + val appCurrency: AppCurrency = AppCurrency.Default, + val local: LocalState = LocalState(), + ) } \ No newline at end of file From 547d562d6de7c7fc013ebe6423e19a34ecaad0d8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Apr 2026 17:26:05 +0400 Subject: [PATCH 021/206] Updated on 2026-08-14 --- .../SwapAmountSelectQuoteTransformer.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt index 19a5e22946..7588c150a5 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSelectQuoteTransformer.kt @@ -62,7 +62,8 @@ internal class SwapAmountSelectQuoteTransformer( ) return prevState.copy( - isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content, + isPrimaryButtonEnabled = quoteUM is SwapQuoteUM.Content && + !hasInsufficientBalance(prevState, quoteContent, newPrimaryAmount), selectedQuote = quoteUM, isShowFCAWarning = isNeedApplyFCARestrictions && quoteUM.provider?.isRestrictedByFCA() == true, primaryAmount = newPrimaryAmount, @@ -202,4 +203,22 @@ internal class SwapAmountSelectQuoteTransformer( prevState.secondaryAmount } } + + private fun hasInsufficientBalance( + prevState: SwapAmountUM.Content, + quoteContent: SwapQuoteUM.Content?, + newPrimaryAmount: SwapAmountFieldUM, + ): Boolean { + if (quoteContent == null) return false + + val primaryBalance = prevState.primaryCryptoCurrencyStatus.value.amount ?: return false + val fromAmount = if (prevState.selectedAmountType == SwapAmountType.To) { + quoteContent.fromAmount + } else { + val amountData = (newPrimaryAmount as? SwapAmountFieldUM.Content)?.amountField as? AmountState.Data + amountData?.amountTextField?.cryptoAmount?.value + } + + return fromAmount != null && fromAmount > primaryBalance + } } \ No newline at end of file From d0ef5c70cd7cae67012243daf6c6d3fe6a150310 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 11 Apr 2026 09:38:37 +0300 Subject: [PATCH 022/206] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 9 ++ core/res/src/main/res/values/strings.xml | 1 + .../ic_dynamic_addresses_badge_16.xml | 9 ++ .../DefaultWalletManagersFacade.kt | 6 +- .../domain/models/TokenReceiveConfig.kt | 6 +- domain/transaction/build.gradle.kts | 2 + .../usecase/ReceiveAddressesFactory.kt | 90 ++++++++++++++----- .../tangempay/model/TangemPayAddFundsModel.kt | 4 +- .../tokenreceive/entity/ReceiveAddress.kt | 2 + .../entity/TokenReceiveStateFactory.kt | 68 ++++++++------ .../ui/TokenReceiveAssetsContent.kt | 36 ++++++++ gradle/tangem_dependencies.toml | 2 +- 12 files changed, 175 insertions(+), 60 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_dynamic_addresses_badge_16.xml diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 9b3b5503e7..d88224fe30 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -5,6 +5,9 @@ import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.demo.models.DemoConfig +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.notifications.repository.PushNotificationsRepository @@ -257,10 +260,16 @@ internal object TransactionDomainModule { fun provideReceiveAddressesFactory( getEnsNameUseCase: GetEnsNameUseCase, getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, + getDynamicReceiveAddressUseCase: GetDynamicReceiveAddressUseCase, + dynamicAddressesRepository: DynamicAddressesRepository, + dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ): ReceiveAddressesFactory { return ReceiveAddressesFactory( getEnsNameUseCase = getEnsNameUseCase, getViewedTokenReceiveWarningUseCase = getViewedTokenReceiveWarningUseCase, + getDynamicReceiveAddressUseCase = getDynamicReceiveAddressUseCase, + dynamicAddressesRepository = dynamicAddressesRepository, + dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, ) } diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 1ec9514666..586600272c 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -487,6 +487,7 @@ Disable dynamic addresses Dynamic addresses disabled Dynamic addresses enabled + Dynamic address Use a new address for each transaction to reduce traceability and improve on-chain privacy. Enhanced Privacy Easily receive funds in UTXO-based networks with automatic address generation — no manual address management required. diff --git a/core/ui/src/main/res/drawable/ic_dynamic_addresses_badge_16.xml b/core/ui/src/main/res/drawable/ic_dynamic_addresses_badge_16.xml new file mode 100644 index 0000000000..5ab98c661f --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_dynamic_addresses_badge_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index 1055b56685..d86119e490 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -476,12 +476,12 @@ internal class DefaultWalletManagersFacade @Inject constructor( val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return null return dynamicAddressesManager.usedAddresses .filter { usedAddress -> - val nodes = runCatching { DerivationPath(usedAddress.path).nodes }.getOrNull() + val nodes = runCatching { DerivationPath(usedAddress.derivationPath).nodes }.getOrNull() ?: return@filter false nodes.size >= XPUB_PATH_MIN_NODES && nodes[nodes.size - 2].index == RECEIVE_CHAIN_INDEX } .maxByOrNull { usedAddress -> - runCatching { DerivationPath(usedAddress.path).nodes.last().index }.getOrDefault(0L) + runCatching { DerivationPath(usedAddress.derivationPath).nodes.last().index }.getOrDefault(0L) } ?.address } @@ -490,7 +490,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return false return dynamicAddressesManager.usedAddresses.any { usedAddress -> - val nodes = runCatching { DerivationPath(usedAddress.path).nodes }.getOrNull() + val nodes = runCatching { DerivationPath(usedAddress.derivationPath).nodes }.getOrNull() ?: return@any false val isBaseAddress = nodes.size >= XPUB_PATH_MIN_NODES && nodes[nodes.size - 2].index == RECEIVE_CHAIN_INDEX && diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt index 467675af70..f0504c15ca 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/TokenReceiveConfig.kt @@ -18,11 +18,11 @@ data class TokenReceiveConfig( @Serializable data class ReceiveAddressModel( - val nameService: NameService, + val displayType: DisplayType, val value: String, ) { - enum class NameService { - Default, Legacy, Ens + enum class DisplayType { + Default, Legacy, Ens, Dynamic, } } diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index aae9c5368c..cc13ce06eb 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -27,6 +27,8 @@ dependencies { implementation(projects.libs.crypto) implementation(projects.domain.account.status) + implementation(projects.domain.dynamicAddresses) + implementation(projects.domain.dynamicAddresses.models) implementation(projects.domain.models) implementation(projects.domain.legacy) implementation(projects.domain.walletManager) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt index e0b596f81e..93a25ab440 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/ReceiveAddressesFactory.kt @@ -1,5 +1,9 @@ package com.tangem.domain.transaction.usecase +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase +import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.Asset import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveConfig @@ -12,10 +16,15 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.GetViewedTokenReceiveWarningUseCase import com.tangem.domain.transaction.R import com.tangem.lib.crypto.BlockchainUtils +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.flow.firstOrNull class ReceiveAddressesFactory( private val getEnsNameUseCase: GetEnsNameUseCase, private val getViewedTokenReceiveWarningUseCase: GetViewedTokenReceiveWarningUseCase, + private val getDynamicReceiveAddressUseCase: GetDynamicReceiveAddressUseCase, + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ) { suspend fun create( @@ -32,27 +41,14 @@ class ReceiveAddressesFactory( address = addresses.defaultAddress.value, ) - val receiveAddresses = buildList { - ensName?.let { ens -> - add( - ReceiveAddressModel( - nameService = ReceiveAddressModel.NameService.Ens, - value = ens, - ), - ) - } - addresses.availableAddresses.map { address -> - add( - ReceiveAddressModel( - nameService = when (address.type) { - NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default - NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy - }, - value = address.value, - ), - ) - } + val dynamicAddress = getDynamicAddressIfEnabled(userWalletId, cryptoCurrency) + + val receiveAddresses = if (dynamicAddress != null) { + buildDynamicAddressList(ensName, dynamicAddress) + } else { + buildStandardAddressList(ensName, addresses) } + return TokenReceiveConfig( shouldShowWarning = cryptoCurrency.name !in getViewedTokenReceiveWarningUseCase(), cryptoCurrency = cryptoCurrency, @@ -64,6 +60,52 @@ class ReceiveAddressesFactory( ) } + private suspend fun getDynamicAddressIfEnabled( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): String? { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return null + if (cryptoCurrency !is CryptoCurrency.Coin) return null + + val status = dynamicAddressesRepository.getStatus(userWalletId, cryptoCurrency.network).firstOrNull() + if (status != DynamicAddressesStatus.ENABLED) return null + + return getDynamicReceiveAddressUseCase(userWalletId, cryptoCurrency.network) + .onLeft { TangemLogger.e("Failed to get dynamic receive address: ${it.message}") } + .getOrNull() + } + + private fun buildDynamicAddressList(ensName: String?, dynamicAddress: String): List = + buildList { + ensName?.let { ens -> + add(ReceiveAddressModel(displayType = ReceiveAddressModel.DisplayType.Ens, value = ens)) + } + add( + ReceiveAddressModel( + displayType = ReceiveAddressModel.DisplayType.Dynamic, + value = dynamicAddress, + ), + ) + } + + private fun buildStandardAddressList(ensName: String?, addresses: NetworkAddress): List = + buildList { + ensName?.let { ens -> + add(ReceiveAddressModel(displayType = ReceiveAddressModel.DisplayType.Ens, value = ens)) + } + addresses.availableAddresses.map { address -> + add( + ReceiveAddressModel( + displayType = when (address.type) { + NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.DisplayType.Default + NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.DisplayType.Legacy + }, + value = address.value, + ), + ) + } + } + suspend fun createForNft( userWalletId: UserWalletId, addresses: NetworkAddress, @@ -82,7 +124,7 @@ class ReceiveAddressesFactory( ensName?.let { ens -> add( ReceiveAddressModel( - nameService = ReceiveAddressModel.NameService.Ens, + displayType = ReceiveAddressModel.DisplayType.Ens, value = ens, ), ) @@ -90,9 +132,9 @@ class ReceiveAddressesFactory( addresses.availableAddresses.map { address -> add( ReceiveAddressModel( - nameService = when (address.type) { - NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.NameService.Default - NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.NameService.Legacy + displayType = when (address.type) { + NetworkAddress.Address.Type.Primary -> ReceiveAddressModel.DisplayType.Default + NetworkAddress.Address.Type.Secondary -> ReceiveAddressModel.DisplayType.Legacy }, value = address.value, ), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt index b1a7a7133a..40b49906f5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayAddFundsModel.kt @@ -5,7 +5,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.models.ReceiveAddressModel -import com.tangem.domain.models.ReceiveAddressModel.NameService +import com.tangem.domain.models.ReceiveAddressModel.DisplayType import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -42,7 +42,7 @@ internal class TangemPayAddFundsModel @Inject constructor( depositAddress = params.depositAddress, receiveAddress = listOf( ReceiveAddressModel( - nameService = NameService.Default, + displayType = DisplayType.Default, value = params.depositAddress, ), ), diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt index b5264d8402..1a65fcfde1 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/ReceiveAddress.kt @@ -19,6 +19,8 @@ internal data class ReceiveAddress( data class Default(override val displayName: TextReference) : Primary data class Legacy(override val displayName: TextReference) : Primary + + data class Dynamic(override val displayName: TextReference) : Primary } } } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt index bcd955a4af..8ca7790141 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt @@ -20,6 +20,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList +@Suppress("MagicNumber") internal class TokenReceiveStateFactory( private val currentStateProvider: Provider, private val cryptoCurrency: CryptoCurrency, @@ -66,8 +67,9 @@ internal class TokenReceiveStateFactory( compareBy { address -> when (address.type) { is Ens -> 0 - is Primary.Default -> 1 - is Primary.Legacy -> 2 + is Primary.Dynamic -> 1 + is Primary.Default -> 2 + is Primary.Legacy -> 3 } }, ) @@ -82,32 +84,18 @@ internal class TokenReceiveStateFactory( addresses: List, cryptoCurrency: CryptoCurrency, ): ImmutableList { - val needUseToLegacyAndDefaultName = addresses.any { it.nameService == ReceiveAddressModel.NameService.Legacy } + val shouldUseToLegacyAndDefaultName = addresses.any { it.displayType == ReceiveAddressModel.DisplayType.Legacy } val receiveAddresses = addresses.map { model -> - val type = when (model.nameService) { - ReceiveAddressModel.NameService.Default -> { - val displayName = when (cryptoCurrency) { - is CryptoCurrency.Coin -> cryptoCurrency.name - is CryptoCurrency.Token -> cryptoCurrency.symbol - } - - Primary.Default( - displayName = if (needUseToLegacyAndDefaultName) { - TextReference.Res(R.string.domain_receive_assets_default_address) - } else { - TextReference.Combined( - wrappedList( - TextReference.Str(displayName), - TextReference.Str(" "), - TextReference.Res(R.string.common_address), - ), - ) - }, - ) + val type = when (model.displayType) { + ReceiveAddressModel.DisplayType.Default -> { + Primary.Default(displayName = defaultDisplayName(cryptoCurrency, shouldUseToLegacyAndDefaultName)) } - ReceiveAddressModel.NameService.Ens -> Ens - ReceiveAddressModel.NameService.Legacy -> Primary.Legacy( + ReceiveAddressModel.DisplayType.Dynamic -> { + Primary.Dynamic(displayName = coinAddressDisplayName(cryptoCurrency)) + } + ReceiveAddressModel.DisplayType.Ens -> Ens + ReceiveAddressModel.DisplayType.Legacy -> Primary.Legacy( displayName = resourceReference( R.string.domain_receive_assets_legacy_address, WrappedList(listOf(cryptoCurrency.name)), @@ -124,8 +112,9 @@ internal class TokenReceiveStateFactory( compareBy { address -> when (address.type) { is Ens -> 0 - is Primary.Default -> 1 - is Primary.Legacy -> 2 + is Primary.Dynamic -> 1 + is Primary.Default -> 2 + is Primary.Legacy -> 3 } }, ) @@ -170,4 +159,29 @@ internal class TokenReceiveStateFactory( isGrayscale = false, shouldShowCustomBadge = false, ) + + private fun defaultDisplayName( + cryptoCurrency: CryptoCurrency, + needUseToLegacyAndDefaultName: Boolean, + ): TextReference { + return if (needUseToLegacyAndDefaultName) { + TextReference.Res(R.string.domain_receive_assets_default_address) + } else { + coinAddressDisplayName(cryptoCurrency) + } + } + + private fun coinAddressDisplayName(cryptoCurrency: CryptoCurrency): TextReference { + val displayName = when (cryptoCurrency) { + is CryptoCurrency.Coin -> cryptoCurrency.name + is CryptoCurrency.Token -> cryptoCurrency.symbol + } + return TextReference.Combined( + wrappedList( + TextReference.Str(displayName), + TextReference.Str(" "), + TextReference.Res(R.string.common_address), + ), + ) + } } \ No newline at end of file diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt index 63202e22b0..61602b31b4 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/ui/TokenReceiveAssetsContent.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -300,6 +301,7 @@ private fun PrimaryAddressesItems( onShareClick = { onShareClick(selectedAddress.value) }, primaryType = selectedAddress.type as ReceiveAddress.Type.Primary, address = selectedAddress.value, + isDynamicAddress = selectedAddress.type is ReceiveAddress.Type.Primary.Dynamic, snackbarHostState = snackbarHostState, ) } @@ -345,6 +347,7 @@ private fun AddressItem( onShareClick: () -> Unit, primaryType: ReceiveAddress.Type.Primary, address: String, + isDynamicAddress: Boolean, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, ) { @@ -369,6 +372,11 @@ private fun AddressItem( SpacerH(12.dp) + if (isDynamicAddress) { + DynamicAddressBadge() + SpacerH(8.dp) + } + Text( text = primaryType.displayName.resolveReference(), color = TangemTheme.colors.text.primary1, @@ -564,6 +572,34 @@ private fun ActionButtonWithResizableText(config: ActionButtonConfig, modifier: ) } +@Composable +private fun DynamicAddressBadge(modifier: Modifier = Modifier) { + Row( + modifier = modifier + .background( + color = TangemTheme.colors.text.accent.copy(alpha = 0.1f), + shape = RoundedCornerShape(percent = 50), + ) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + painter = painterResource( + id = R.drawable.ic_dynamic_addresses_badge_16, + ), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = TangemTheme.colors.icon.accent, + ) + Text( + text = stringResourceSafe(R.string.dynamic_addresses_receive_badge), + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.accent, + ) + } +} + @Composable @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index d9b87d6b95..7dce45b0c8 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1486" +tangemBlockchainSdk = "develop-1487" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-602" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 948a63d6b6f764f2580615c978e4c73706f95fcc Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Apr 2026 14:20:59 +0400 Subject: [PATCH 023/206] Updated on 2026-08-14 --- .../local/network/entity/NetworkStatusDM.kt | 16 ++++-- data/networks/detekt-baseline-debug.xml | 15 ------ .../converters/NetworkAmountsConverter.kt | 18 +++---- ...erter.kt => NetworkCurrencyIdConverter.kt} | 22 ++++---- .../NetworkStatusDataModelConverter.kt | 4 +- .../NetworkYieldSupplyStatusConverter.kt | 16 +++--- .../SimpleNetworkStatusConverter.kt | 10 ++-- .../di/NetworkStatusSupplierModule.kt | 19 ++++--- .../multi/DefaultMultiNetworkStatusFetcher.kt | 4 +- .../repository/DefaultNetworksRepository.kt | 54 +++++++++---------- .../converters/NetworkAmountsConverterTest.kt | 16 +++--- ...t.kt => NetworkCurrencyIdConverterTest.kt} | 26 ++++----- .../NetworkStatusDataModelConverterTest.kt | 2 +- .../NetworkYieldSupplyStatusConverterTest.kt | 12 ++--- .../tangem/domain/models/network/Network.kt | 2 +- domain/networks/detekt-baseline-main.xml | 8 --- .../multi/MultiNetworkStatusSupplier.kt | 2 +- .../single/SingleNetworkStatusSupplier.kt | 2 +- .../com/tangem/lib/crypto/BlockchainUtils.kt | 2 +- 19 files changed, 117 insertions(+), 133 deletions(-) delete mode 100644 data/networks/detekt-baseline-debug.xml rename data/networks/src/main/java/com/tangem/data/networks/converters/{CurrencyIdConverter.kt => NetworkCurrencyIdConverter.kt} (86%) rename data/networks/src/test/java/com/tangem/data/networks/converters/{CurrencyIdConverterTest.kt => NetworkCurrencyIdConverterTest.kt} (84%) delete mode 100644 domain/networks/detekt-baseline-main.xml diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt index 8d7cd539c0..f05d2b8240 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/network/entity/NetworkStatusDM.kt @@ -29,11 +29,12 @@ sealed interface NetworkStatusDM { /** * Verified * - * @property networkId network id - * @property derivationPath derivation path - * @property selectedAddress selected address - * @property availableAddresses available addresses - * @property amounts amounts + * @property networkId network id + * @property derivationPath derivation path + * @property selectedAddress selected address + * @property availableAddresses available addresses + * @property amounts amounts + * @property yieldSupplyStatuses yield supply statuses */ @NameLabel("amounts") data class Verified( @@ -65,6 +66,11 @@ sealed interface NetworkStatusDM { @Json(name = "error_message") val errorMessage: String, ) : NetworkStatusDM + /** + * Id + * + * @property value blockchain id [com.tangem.blockchain.common.Blockchain.id] + */ @JsonClass(generateAdapter = true) data class ID( @Json(name = "value") val value: String, diff --git a/data/networks/detekt-baseline-debug.xml b/data/networks/detekt-baseline-debug.xml deleted file mode 100644 index 981dd137d6..0000000000 --- a/data/networks/detekt-baseline-debug.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - MultilineLambdaItParameter:CommonNetworkStatusFetcher.kt$CommonNetworkStatusFetcher${ Timber.e("Failed to fetch network status for $userWalletId [${network.rawId}]: $it") networksStatusesStore.setSourceAsOnlyCache(userWalletId = userWalletId, network = network) } - MultilineLambdaItParameter:DefaultMultiNetworkStatusFetcher.kt$DefaultMultiNetworkStatusFetcher${ networksStatusesStore.setSourceAsOnlyCache( userWalletId = params.userWalletId, networks = params.networks, ) raise(it) } - MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return emptyList() } - MultilineLambdaItParameter:DefaultNetworksRepository.kt$DefaultNetworksRepository${ Timber.e(it, "Unable to create wallet currencies") return@withContext } - MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null CurrencyAmount( id = currencyIdConverter.convertBack(value = it.key), amount = amount.value, ) } - MultilineLambdaItParameter:NetworkAmountsConverter.kt$NetworkAmountsConverter${ val currencyId = currencyIdConverter.convert(value = it.id) val amount = NetworkStatus.Amount.Loaded(value = it.amount) currencyId to amount } - MultilineLambdaItParameter:NetworkStatusSupplierModule.kt$NetworkStatusSupplierModule.<no name provided>${ "single_network_status_${it.userWalletId.stringValue}_${it.network.rawId}_" + it.network.derivationPath.value } - MultilineLambdaItParameter:NetworkYieldSupplyStatusConverter.kt$NetworkYieldSupplyStatusConverter${ val id = currencyIdConverter.convert(value = it.id) val status = YieldSupplyStatus( isActive = it.isActive, isInitialized = it.isInitialized, isAllowedToSpend = it.isAllowedToSpend, effectiveProtocolBalance = it.effectiveProtocolBalance, ) id to status } - SuspendFunSwallowedCancellation:DefaultNetworksRepository.kt$DefaultNetworksRepository$runCatching - - diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt index a2876c0071..dc74b16cd8 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkAmountsConverter.kt @@ -12,33 +12,33 @@ private typealias AmountsDomainModel = Map { - private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath) + private val currencyIdConverter = NetworkCurrencyIdConverter(blockchainId, derivationPath) override fun convert(value: AmountsDataModel): AmountsDomainModel { - return value.associate { - val currencyId = currencyIdConverter.convert(value = it.id) - val amount = NetworkStatus.Amount.Loaded(value = it.amount) + return value.associate { currencyAmount -> + val currencyId = currencyIdConverter.convert(value = currencyAmount.id) + val amount = NetworkStatus.Amount.Loaded(value = currencyAmount.amount) currencyId to amount } } override fun convertBack(value: AmountsDomainModel): AmountsDataModel { - return value.mapNotNull { - val amount = it.value as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null + return value.mapNotNull { (currencyId, networkAmount) -> + val amount = networkAmount as? NetworkStatus.Amount.Loaded ?: return@mapNotNull null CurrencyAmount( - id = currencyIdConverter.convertBack(value = it.key), + id = currencyIdConverter.convertBack(value = currencyId), amount = amount.value, ) } diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/CurrencyIdConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt similarity index 86% rename from data/networks/src/main/java/com/tangem/data/networks/converters/CurrencyIdConverter.kt rename to data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt index 728b1db741..8bef0658ac 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/CurrencyIdConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt @@ -1,6 +1,6 @@ package com.tangem.data.networks.converters -import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toCoinId import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId.Companion.CONTRACT_ADDRESS_DELIMITER @@ -12,13 +12,13 @@ import com.tangem.domain.models.currency.CryptoCurrency.ID.Suffix as CurrencyIdS /** * Converts between [CurrencyId] and [CryptoCurrency.ID]. * - * @property rawNetworkId the raw network ID associated with the currency + * @property blockchainId the blockchain ID associated with the currency * @property derivationPath the derivation path used for the network * [REDACTED_AUTHOR] */ -internal class CurrencyIdConverter( - private val rawNetworkId: String, +internal class NetworkCurrencyIdConverter( + private val blockchainId: String, private val derivationPath: Network.DerivationPath, ) : TwoWayConverter { @@ -31,7 +31,7 @@ internal class CurrencyIdConverter( return if (contractAddress.isNullOrBlank()) { getCoinId( coinId = rawId.takeUnless { it.isNullOrBlank() } - ?: error("Coin id is null for $rawNetworkId with $derivationPath"), + ?: error("Coin id is null for $blockchainId with $derivationPath"), ) } else { getTokenId( @@ -43,14 +43,12 @@ internal class CurrencyIdConverter( override fun convertBack(value: CryptoCurrency.ID): CurrencyId { return if (value.isCoin) { - CurrencyId.createCoinId( - coinId = Blockchain.fromId(value.rawNetworkId).toCoinId(), - ) + CurrencyId.createCoinId(coinId = value.toBlockchain().toCoinId()) } else { CurrencyId.createTokenId( rawTokenId = value.rawCurrencyId?.value, contractAddress = requireNotNull(value.contractAddress) { - "Token contractAddress is null for token id: $this" + "Token contractAddress is null for token id: $value" }, ) } @@ -82,17 +80,17 @@ internal class CurrencyIdConverter( return when (derivationPath) { is Network.DerivationPath.Card -> { CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( - rawId = rawNetworkId, + rawId = blockchainId, derivationPath = derivationPath.value, ) } is Network.DerivationPath.Custom -> { CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( - rawId = rawNetworkId, + rawId = blockchainId, derivationPath = derivationPath.value, ) } - is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(rawNetworkId) + is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(blockchainId) } } } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt index d876b01234..7f0d1b6fd3 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverter.kt @@ -18,11 +18,11 @@ internal object NetworkStatusDataModelConverter : Converter internal class NetworkYieldSupplyStatusConverter( - rawNetworkId: String, + blockchainId: String, derivationPath: Network.DerivationPath, ) : TwoWayConverter { - private val currencyIdConverter = CurrencyIdConverter(rawNetworkId, derivationPath) + private val currencyIdConverter = NetworkCurrencyIdConverter(blockchainId, derivationPath) override fun convert(value: YieldSupplyStatusDataModel): YieldSupplyStatusDomainModel { - return value.associate { - val id = currencyIdConverter.convert(value = it.id) + return value.associate { yieldSupplyStatus -> + val id = currencyIdConverter.convert(value = yieldSupplyStatus.id) val status = YieldSupplyStatus( - isActive = it.isActive, - isInitialized = it.isInitialized, - isAllowedToSpend = it.isAllowedToSpend, - effectiveProtocolBalance = it.effectiveProtocolBalance, + isActive = yieldSupplyStatus.isActive, + isInitialized = yieldSupplyStatus.isInitialized, + isAllowedToSpend = yieldSupplyStatus.isAllowedToSpend, + effectiveProtocolBalance = yieldSupplyStatus.effectiveProtocolBalance, ) id to status diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt index b82bfb9455..940cfdea59 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverter.kt @@ -25,15 +25,15 @@ internal object SimpleNetworkStatusConverter : Converter + listOf( + "single_network_status", + params.userWalletId.stringValue, + params.network.rawId, + params.network.derivationPath.value, + ) + .joinToString(separator = "_") }, - ) {} + ) } @Provides @Singleton fun provideMultiNetworkStatusSupplier(factory: MultiNetworkStatusProducer.Factory): MultiNetworkStatusSupplier { - return object : MultiNetworkStatusSupplier( + return MultiNetworkStatusSupplier( factory = factory, keyCreator = { "multi_networks_statuses_${it.userWalletId.stringValue}" }, - ) {} + ) } } \ No newline at end of file diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt index b039edc5ba..e429c134da 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt @@ -40,13 +40,13 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor( val networksCurrencies = catch( block = { createNetworksCurrenciesMap(params) }, - catch = { + catch = { error -> networksStatusesStore.setSourceAsOnlyCache( userWalletId = params.userWalletId, networks = params.networks, ) - raise(it) + raise(error) }, ) diff --git a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt index b5a2fb854c..defc120690 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/repository/DefaultNetworksRepository.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.repository.NetworksRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.withContext @@ -33,13 +34,12 @@ internal class DefaultNetworksRepository( override suspend fun fetchPendingTransactions(userWalletId: UserWalletId, network: Network) { withContext(dispatchers.default) { - val currencies = runCatching { + val currencies = runSuspendCatching { cardCryptoCurrencyFactory.create(userWalletId = userWalletId, network = network) + }.getOrElse { error -> + TangemLogger.e("Unable to create wallet currencies", error) + return@withContext } - .getOrElse { error -> - TangemLogger.e("Unable to create wallet currencies", error) - return@withContext - } fetchPendingTransactions(userWalletId = userWalletId, network = network, currencies = currencies) } @@ -49,34 +49,34 @@ internal class DefaultNetworksRepository( userWalletId: UserWalletId, network: Network, ): List { - return runCatching { cardCryptoCurrencyFactory.create(userWalletId = userWalletId, network = network) } - .getOrElse { error -> - TangemLogger.e("Unable to create wallet currencies", error) - return emptyList() - } - .map { currency -> - CryptoCurrencyAddress( - cryptoCurrency = currency, - address = getDefaultAddress(userWalletId, network).orEmpty(), - ) - } + return runSuspendCatching { + cardCryptoCurrencyFactory.create(userWalletId = userWalletId, network = network) + }.getOrElse { error -> + TangemLogger.e("Unable to create wallet currencies", error) + return emptyList() + }.map { currency -> + CryptoCurrencyAddress( + cryptoCurrency = currency, + address = getDefaultAddress(userWalletId, network).orEmpty(), + ) + } } override suspend fun getNetworkAddresses( userWalletId: UserWalletId, network: Network.RawID, ): List { - return runCatching { cardCryptoCurrencyFactory.createByRawId(userWalletId = userWalletId, network = network) } - .getOrElse { error -> - TangemLogger.e("Unable to create wallet currencies", error) - return emptyList() - } - .map { currency -> - CryptoCurrencyAddress( - cryptoCurrency = currency, - address = getDefaultAddress(userWalletId, currency.network).orEmpty(), - ) - } + return runSuspendCatching { + cardCryptoCurrencyFactory.createByRawId(userWalletId = userWalletId, network = network) + }.getOrElse { error -> + TangemLogger.e("Unable to create wallet currencies", error) + return emptyList() + }.map { currency -> + CryptoCurrencyAddress( + cryptoCurrency = currency, + address = getDefaultAddress(userWalletId, currency.network).orEmpty(), + ) + } } override suspend fun getDefaultAddress(userWalletId: UserWalletId, network: Network): String? { diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt index 5bbe4daf2d..4ca75519b9 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt @@ -16,10 +16,10 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkAmountsConverterTest { - private val rawNetworkId = "ETH" + private val rawNetworkId = "ethereum" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" - private val converter = NetworkAmountsConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath) + private val converter = NetworkAmountsConverter(blockchainId = rawNetworkId, derivationPath = derivationPath) @Test fun convert() { @@ -47,12 +47,12 @@ internal class NetworkAmountsConverterTest { // Assert val expected = mapOf( - ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), + ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.ZERO), ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.TEN), ) @@ -63,12 +63,12 @@ internal class NetworkAmountsConverterTest { fun convertBack() { // Arrange val value = mapOf( - ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), + ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum") to Loaded(value = BigDecimal.ONE), ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.ZERO), ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ) to Loaded(value = BigDecimal.TEN), ) diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt similarity index 84% rename from data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt rename to data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt index 9cdcc4810e..9e129e6cd4 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/CurrencyIdConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt @@ -13,12 +13,12 @@ import org.junit.jupiter.params.ParameterizedTest [REDACTED_AUTHOR] */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class CurrencyIdConverterTest { +class NetworkCurrencyIdConverterTest { - private val rawNetworkId = "ETH" + private val rawNetworkId = "ethereum" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" - private val converter = CurrencyIdConverter(rawNetworkId = rawNetworkId, derivationPath = derivationPath) + private val converter = NetworkCurrencyIdConverter(blockchainId = rawNetworkId, derivationPath = derivationPath) @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -48,7 +48,7 @@ class CurrencyIdConverterTest { ConvertModel( value = CurrencyId.createCoinId("ethereum"), expected = Result.success( - CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩ethereum"), + CryptoCurrency.ID.fromValue(value = "coin⟨ethereum→$derivationPathHashCode⟩ethereum"), ), ), ConvertModel( @@ -71,7 +71,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -82,7 +82,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -93,7 +93,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -104,7 +104,7 @@ class CurrencyIdConverterTest { ), expected = Result.success( CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), ), ), @@ -114,7 +114,7 @@ class CurrencyIdConverterTest { contractAddress = "", ), expected = Result.success( - CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩usdt"), + CryptoCurrency.ID.fromValue(value = "coin⟨ethereum→$derivationPathHashCode⟩usdt"), ), ), ConvertModel( @@ -123,7 +123,7 @@ class CurrencyIdConverterTest { contractAddress = " ", ), expected = Result.success( - CryptoCurrency.ID.fromValue(value = "coin⟨ETH→$derivationPathHashCode⟩usdt"), + CryptoCurrency.ID.fromValue(value = "coin⟨ethereum→$derivationPathHashCode⟩usdt"), ), ), ) @@ -154,14 +154,14 @@ class CurrencyIdConverterTest { private fun provideTestModels(): Collection = listOf( ConvertBackModel( - value = CryptoCurrency.ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum"), + value = CryptoCurrency.ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum"), expected = Result.success( CurrencyId.createCoinId("ethereum"), ), ), ConvertBackModel( value = CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩usdt⚓0xdAC17F958D2ee523a2206206994597C13D831ec7", ), expected = Result.success( CurrencyId.createTokenId( @@ -172,7 +172,7 @@ class CurrencyIdConverterTest { ), ConvertBackModel( value = CryptoCurrency.ID.fromValue( - value = "token⟨ETH→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", + value = "token⟨ethereum→$derivationPathHashCode⟩0xdAC17F958D2ee523a2206206994597C13D831ec7", ), expected = Result.success( CurrencyId.createTokenId( diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt index b995196d80..a667da490a 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkStatusDataModelConverterTest.kt @@ -50,7 +50,7 @@ internal class NetworkStatusDataModelConverterTest { ), ), amounts = mapOf( - ID.fromValue(value = "coin⟨ETH→0⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), + ID.fromValue(value = "coin⟨ethereum→0⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), ID( prefix = Prefix.COIN_PREFIX, body = Body.NetworkId(rawId = "BTC"), diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt index af36491079..b18f2e14d2 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt @@ -13,7 +13,7 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkYieldSupplyStatusConverterTest { - private val rawNetworkId = "ETH" + private val rawNetworkId = "ethereum" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" private val converter = NetworkYieldSupplyStatusConverter(rawNetworkId, derivationPath) @@ -38,8 +38,8 @@ internal class NetworkYieldSupplyStatusConverterTest { // Assert val expected = mapOf( - ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to domainStatus, - ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, + ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum") to domainStatus, + ID.fromValue("token⟨ethereum→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, ) Truth.assertThat(actual).containsExactlyEntriesIn(expected) @@ -49,9 +49,9 @@ internal class NetworkYieldSupplyStatusConverterTest { fun convertBack() { // Arrange val value = mapOf( - ID.fromValue("coin⟨ETH→$derivationPathHashCode⟩ethereum") to domainStatus, - ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, - ID.fromValue("token⟨ETH→$derivationPathHashCode⟩usdc⚓0x1") to null, + ID.fromValue("coin⟨ethereum→$derivationPathHashCode⟩ethereum") to domainStatus, + ID.fromValue("token⟨ethereum→$derivationPathHashCode⟩usdt⚓0x1") to domainStatus, + ID.fromValue("token⟨ethereum→$derivationPathHashCode⟩usdc⚓0x1") to null, ) // Act diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt index 489ede3a4d..028a16d2a2 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/Network.kt @@ -47,7 +47,7 @@ data class Network( /** * Represents a unique identifier for a blockchain network * - * @property rawId raw network ID + * @property rawId raw network ID (backend id) * @property derivationPath derivation path */ @Serializable diff --git a/domain/networks/detekt-baseline-main.xml b/domain/networks/detekt-baseline-main.xml deleted file mode 100644 index eaf966bb38..0000000000 --- a/domain/networks/detekt-baseline-main.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - UnnecessaryAbstractClass:MultiNetworkStatusSupplier.kt$MultiNetworkStatusSupplier$MultiNetworkStatusSupplier - UnnecessaryAbstractClass:SingleNetworkStatusSupplier.kt$SingleNetworkStatusSupplier$SingleNetworkStatusSupplier - - diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt index 65d7623cdc..ea96369684 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusSupplier.kt @@ -11,7 +11,7 @@ import com.tangem.domain.models.network.NetworkStatus * [REDACTED_AUTHOR] */ -abstract class MultiNetworkStatusSupplier( +open class MultiNetworkStatusSupplier( override val factory: MultiNetworkStatusProducer.Factory, override val keyCreator: (MultiNetworkStatusProducer.Params) -> String, ) : FlowCachingSupplier>() \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt index 8fc7770c0f..117216c91f 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusSupplier.kt @@ -11,7 +11,7 @@ import com.tangem.domain.models.network.NetworkStatus * [REDACTED_AUTHOR] */ -abstract class SingleNetworkStatusSupplier( +open class SingleNetworkStatusSupplier( override val factory: SingleNetworkStatusProducer.Factory, override val keyCreator: (SingleNetworkStatusProducer.Params) -> String, ) : FlowCachingSupplier() \ No newline at end of file diff --git a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt index fe00a42eb6..f3e5669802 100644 --- a/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt +++ b/libs/crypto/src/main/java/com/tangem/lib/crypto/BlockchainUtils.kt @@ -42,7 +42,7 @@ object BlockchainUtils { return blockchain == Blockchain.Bitcoin || blockchain == Blockchain.BitcoinTestnet } - /** Checks if the current [blockchainId] uses a custom fee converter */ + /** Checks if the current [networkId] uses a custom fee converter */ fun isUseBitcoinFeeConverter(networkId: String): Boolean { val blockchain = networkId.toBlockchain() return isBitcoin(networkId) || blockchain == Blockchain.Fact0rn From 2e431b9261b8158312ff644efb51322a34cfda5d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Apr 2026 12:03:16 +0300 Subject: [PATCH 024/206] Updated on 2026-08-14 --- .../configs/excluded_blockchains_config.json | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json b/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json index 59809c4e40..3d5c1150dc 100644 --- a/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json +++ b/core/config-toggles/src/main/assets/configs/excluded_blockchains_config.json @@ -15,24 +15,8 @@ "name": "vanar-chain", "version": "undefined" }, - { - "name": "sonic", - "version": "5.21.0" - }, - { - "name": "apechain", - "version": "5.21.0" - }, - { - "name": "alephium", - "version": "5.21.0" - }, { "name": "zklink", "version": "undefined" - }, - { - "name": "plasma", - "version": "5.31" } ] \ No newline at end of file From 9ef791c432ef32a00be0143538febcd42bc067c2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Apr 2026 16:33:29 +0400 Subject: [PATCH 025/206] Updated on 2026-08-14 --- .../appsettings/AppSettingsDialogsFactory.kt | 86 ++++++-------- .../ui/appsettings/AppSettingsScreen.kt | 16 +-- .../ui/appsettings/AppSettingsScreenState.kt | 27 +---- .../DefaultAppSettingsComponent.kt | 32 ++++-- .../ui/appsettings/SettingsSelectorDialog.kt | 38 +++++++ .../components/SettingsAlertDialog.kt | 54 --------- .../components/SettingsSelectorDialog.kt | 52 --------- .../model/AppSettingsDialogConfig.kt | 10 ++ .../ui/appsettings/model/AppSettingsModel.kt | 106 +++++++----------- 9 files changed, 152 insertions(+), 269 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/appsettings/SettingsSelectorDialog.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt create mode 100644 app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsDialogConfig.kt diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsDialogsFactory.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsDialogsFactory.kt index 106fad8b50..20d7536526 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsDialogsFactory.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsDialogsFactory.kt @@ -2,71 +2,59 @@ package com.tangem.tap.features.details.ui.appsettings import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Dialog +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.EventMessageAction import com.tangem.wallet.R -import kotlinx.collections.immutable.toImmutableList internal class AppSettingsDialogsFactory { - fun createThemeModeSelectorDialog( - selectedModeIndex: Int, - onSelect: (AppThemeMode) -> Unit, - onDismiss: () -> Unit, - ): Dialog.Selector { - val modes = AppThemeMode.available - - return Dialog.Selector( - title = resourceReference(R.string.app_settings_theme_selector_title), - selectedItemIndex = selectedModeIndex, - items = modes.map { mode -> - resourceReference( - id = when (mode) { - AppThemeMode.FORCE_DARK -> R.string.app_settings_theme_mode_dark - AppThemeMode.FORCE_LIGHT -> R.string.app_settings_theme_mode_light - AppThemeMode.FOLLOW_SYSTEM -> R.string.app_settings_theme_mode_system - }, - ) - }.toImmutableList(), - onSelect = { index -> - val mode = AppThemeMode.available[index] - - onSelect(mode) - }, - onDismiss = onDismiss, - ) - } - - fun createDisableBiometricAuthenticationAlert(onDisable: () -> Unit, onDismiss: () -> Unit): Dialog.Alert { - return Dialog.Alert( + fun createDisableBiometricAuthenticationAlert(onDisable: () -> Unit): DialogMessage { + return DialogMessage( title = resourceReference(R.string.common_attention), - description = resourceReference( + message = resourceReference( R.string.app_settings_off_biometrics_alert_message, wrappedList(resourceReference(R.string.common_biometrics)), ), - confirmText = resourceReference(R.string.common_disable), - onConfirm = onDisable, - onDismiss = onDismiss, + isDismissable = false, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_disable), + isWarning = true, + onClick = onDisable, + ) + }, + secondActionBuilder = { cancelAction() }, ) } - fun createEnableRequireAccessCodeAlert(onEnable: () -> Unit, onDismiss: () -> Unit): Dialog.Alert { - return Dialog.Alert( + fun createEnableRequireAccessCodeAlert(onEnable: () -> Unit): DialogMessage { + return DialogMessage( title = resourceReference(R.string.common_attention), - description = resourceReference(R.string.app_settings_on_require_access_code_alert_message), - confirmText = resourceReference(R.string.common_enable), - onConfirm = { onEnable() }, - onDismiss = onDismiss, + message = resourceReference(R.string.app_settings_on_require_access_code_alert_message), + isDismissable = false, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_enable), + onClick = onEnable, + ) + }, + secondActionBuilder = { cancelAction() }, ) } - fun createDisableRequireAccessCodeAlert(onDisable: () -> Unit, onDismiss: () -> Unit): Dialog.Alert { - return Dialog.Alert( + fun createDisableRequireAccessCodeAlert(onDisable: () -> Unit): DialogMessage { + return DialogMessage( title = resourceReference(R.string.common_attention), - description = resourceReference(R.string.app_settings_off_require_access_code_alert_message), - confirmText = resourceReference(R.string.common_disable), - onConfirm = { onDisable() }, - onDismiss = onDismiss, + message = resourceReference(R.string.app_settings_off_require_access_code_alert_message), + isDismissable = false, + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_disable), + isWarning = true, + onClick = onDisable, + ) + }, + secondActionBuilder = { cancelAction() }, ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt index d17e6b23b6..8ffb314316 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt @@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview @@ -42,13 +40,6 @@ internal fun AppSettingsScreen(state: AppSettingsScreenState, onBackClick: () -> @Composable private fun AppSettings(state: AppSettingsScreenState.Content) { - val dialog by rememberUpdatedState(newValue = state.dialog) - when (val safeDialog = dialog) { - is AppSettingsScreenState.Dialog.Alert -> SettingsAlertDialog(dialog = safeDialog) - is AppSettingsScreenState.Dialog.Selector -> SettingsSelectorDialog(dialog = safeDialog) - null -> Unit - } - val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } LazyColumn( @@ -102,12 +93,7 @@ private class AppSettingsScreenStateProvider : CollectionPreviewParameterProvide itemsFactory.createSelectThemeModeButton(AppThemeMode.DEFAULT, {}), ) - add( - AppSettingsScreenState.Content( - items = items, - dialog = null, - ), - ) + add(AppSettingsScreenState.Content(items = items)) }, ) // endregion Preview \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt index f73524e227..615d6248ce 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreenState.kt @@ -10,10 +10,7 @@ internal sealed class AppSettingsScreenState { object Loading : AppSettingsScreenState() - data class Content( - val items: ImmutableList, - val dialog: Dialog?, - ) : AppSettingsScreenState() + data class Content(val items: ImmutableList) : AppSettingsScreenState() @Immutable sealed class Item { @@ -46,26 +43,4 @@ internal sealed class AppSettingsScreenState { val onClick: () -> Unit, ) : Item() } - - @Immutable - sealed class Dialog { - - abstract val onDismiss: () -> Unit - - data class Alert( - val title: TextReference, - val description: TextReference, - val confirmText: TextReference, - val onConfirm: () -> Unit, - override val onDismiss: () -> Unit, - ) : Dialog() - - data class Selector( - val title: TextReference, - val selectedItemIndex: Int, - val items: ImmutableList, - val onSelect: (Int) -> Unit, - override val onDismiss: () -> Unit, - ) : Dialog() - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/DefaultAppSettingsComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/DefaultAppSettingsComponent.kt index a92b632003..116aebd3d5 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/DefaultAppSettingsComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/DefaultAppSettingsComponent.kt @@ -4,42 +4,56 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.essenty.lifecycle.doOnResume -import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.appsettings.api.AppSettingsComponent +import com.tangem.tap.features.details.ui.appsettings.model.AppSettingsDialogConfig import com.tangem.tap.features.details.ui.appsettings.model.AppSettingsModel -import com.tangem.tap.store import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -@Suppress("UnusedPrivateMember") internal class DefaultAppSettingsComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: Unit, + @Suppress("UnusedPrivateMember") @Assisted params: Unit, ) : AppSettingsComponent, AppComponentContext by appComponentContext { private val model: AppSettingsModel = getOrCreateModel() - init { + private val dialogSlot = childSlot( + source = model.dialogNavigation, + serializer = AppSettingsDialogConfig.serializer(), + handleBackButton = true, + childFactory = { config, _ -> config }, + ) + init { doOnResume { model.onResume() } } @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val dialog by dialogSlot.subscribeAsState() AppSettingsScreen( modifier = modifier, state = state, - onBackClick = { - store.dispatchNavigationAction(AppRouter::pop) - }, + onBackClick = model::onBackClick, ) + + dialog.child?.instance?.let { config -> + when (config) { + is AppSettingsDialogConfig.ThemeModeSelector -> SettingsSelectorDialog( + config = config, + onSelect = model::onThemeModeSelected, + onDismiss = model::dismissDialog, + ) + } + } } @AssistedFactory diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/SettingsSelectorDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/SettingsSelectorDialog.kt new file mode 100644 index 0000000000..cf9f888c0e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/SettingsSelectorDialog.kt @@ -0,0 +1,38 @@ +package com.tangem.tap.features.details.ui.appsettings + +import androidx.compose.runtime.Composable +import com.tangem.core.ui.components.DialogButtonUM +import com.tangem.core.ui.components.SelectorDialog +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.domain.apptheme.model.AppThemeMode +import com.tangem.tap.features.details.ui.appsettings.model.AppSettingsDialogConfig +import com.tangem.wallet.R +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun SettingsSelectorDialog( + config: AppSettingsDialogConfig.ThemeModeSelector, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val modes = AppThemeMode.available + SelectorDialog( + title = stringResourceSafe(R.string.app_settings_theme_selector_title), + selectedItemIndex = config.selectedModeIndex, + items = modes.map { mode -> + stringResourceSafe( + id = when (mode) { + AppThemeMode.FORCE_DARK -> R.string.app_settings_theme_mode_dark + AppThemeMode.FORCE_LIGHT -> R.string.app_settings_theme_mode_light + AppThemeMode.FOLLOW_SYSTEM -> R.string.app_settings_theme_mode_system + }, + ) + }.toImmutableList(), + confirmButton = DialogButtonUM( + title = stringResourceSafe(R.string.common_cancel), + onClick = onDismiss, + ), + onSelect = onSelect, + onDismissDialog = onDismiss, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt deleted file mode 100644 index ff7916fd68..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsAlertDialog.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.tangem.tap.features.details.ui.appsettings.components - -import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.components.BasicDialog -import com.tangem.core.ui.components.DialogButtonUM -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory -import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Dialog -import com.tangem.wallet.R - -@Composable -internal fun SettingsAlertDialog(dialog: Dialog.Alert) { - BasicDialog( - title = dialog.title.resolveReference(), - message = dialog.description.resolveReference(), - isDismissable = false, - confirmButton = DialogButtonUM( - title = dialog.confirmText.resolveReference(), - isWarning = true, - onClick = dialog.onConfirm, - ), - dismissButton = DialogButtonUM( - title = stringResourceSafe(id = R.string.common_cancel), - onClick = dialog.onDismiss, - ), - onDismissDialog = dialog.onDismiss, - ) -} - -// region Preview -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun AlertDialogPreview(@PreviewParameter(AlertDialogProvider::class) dialog: Dialog.Alert) { - TangemThemePreview { - SettingsAlertDialog(dialog = dialog) - } -} - -private class AlertDialogProvider : CollectionPreviewParameterProvider( - collection = buildList { - val dialogsFactory = AppSettingsDialogsFactory() - - add(dialogsFactory.createThemeModeSelectorDialog(selectedModeIndex = 0, onSelect = {}, onDismiss = {})) - add(dialogsFactory.createDisableBiometricAuthenticationAlert(onDisable = {}, onDismiss = {})) - }, -) -// endregion Preview \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt deleted file mode 100644 index 3dba72ed5d..0000000000 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/components/SettingsSelectorDialog.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.tangem.tap.features.details.ui.appsettings.components - -import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.components.DialogButtonUM -import com.tangem.core.ui.components.SelectorDialog -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.tap.features.details.ui.appsettings.AppSettingsDialogsFactory -import com.tangem.tap.features.details.ui.appsettings.AppSettingsScreenState.Dialog -import com.tangem.wallet.R -import kotlinx.collections.immutable.toImmutableList - -@Composable -internal fun SettingsSelectorDialog(dialog: Dialog.Selector) { - SelectorDialog( - title = dialog.title.resolveReference(), - selectedItemIndex = dialog.selectedItemIndex, - items = dialog.items.map { it.resolveReference() }.toImmutableList(), - confirmButton = DialogButtonUM( - title = stringResourceSafe(R.string.common_cancel), - onClick = dialog.onDismiss, - ), - onSelect = dialog.onSelect, - onDismissDialog = dialog.onDismiss, - ) -} - -// region Preview -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun SettingsSelectorDialogPreview(@PreviewParameter(DialogProvider::class) param: Dialog.Selector) { - TangemThemePreview { - SettingsSelectorDialog(param) - } -} - -private class DialogProvider : CollectionPreviewParameterProvider( - collection = listOf( - AppSettingsDialogsFactory().createThemeModeSelectorDialog( - selectedModeIndex = 0, - onSelect = {}, - onDismiss = {}, - ), - ), -) -// endregion Preview \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsDialogConfig.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsDialogConfig.kt new file mode 100644 index 0000000000..4a53c9dc3e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsDialogConfig.kt @@ -0,0 +1,10 @@ +package com.tangem.tap.features.details.ui.appsettings.model + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface AppSettingsDialogConfig { + + @Serializable + data class ThemeModeSelector(val selectedModeIndex: Int) : AppSettingsDialogConfig +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt index c9edf25848..181ae202e1 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt @@ -1,6 +1,9 @@ package com.tangem.tap.features.details.ui.appsettings.model import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.doOnFailure import com.tangem.common.doOnSuccess import com.tangem.common.routing.AppRoute @@ -67,6 +70,8 @@ internal class AppSettingsModel @Inject constructor( private val itemsFactory = AppSettingsItemsFactory() private val dialogsFactory = AppSettingsDialogsFactory() + val dialogNavigation: SlotNavigation = SlotNavigation() + private val localState = MutableStateFlow(LocalState()) private val biometricsStatusJobHolder = JobHolder() @@ -94,10 +99,7 @@ internal class AppSettingsModel @Inject constructor( uiState.update { prevState -> when (prevState) { is AppSettingsScreenState.Content -> prevState.copy(items = items) - is AppSettingsScreenState.Loading -> AppSettingsScreenState.Content( - items = items, - dialog = null, - ) + is AppSettingsScreenState.Loading -> AppSettingsScreenState.Content(items = items) } } } @@ -188,28 +190,31 @@ internal class AppSettingsModel @Inject constructor( settingsManager.openBiometricSettings() } + fun onBackClick() { + router.pop() + } + private fun showAppCurrencySelector() { router.push(AppRoute.AppCurrencySelector) } private fun showThemeModeSelector(selectedMode: AppThemeMode) { - updateContentState { - copy( - dialog = dialogsFactory.createThemeModeSelectorDialog( - selectedModeIndex = selectedMode.ordinal, - onSelect = { mode -> - analyticsEventHandler.send( - event = Settings.AppSettings.ThemeSwitched( - theme = AnalyticsParam.AppTheme.fromAppThemeMode(mode), - ), - ) - changeAppThemeMode(mode) - dismissDialog() - }, - onDismiss = ::dismissDialog, - ), - ) - } + dialogNavigation.activate(AppSettingsDialogConfig.ThemeModeSelector(selectedMode.ordinal)) + } + + fun onThemeModeSelected(index: Int) { + val mode = AppThemeMode.available[index] + analyticsEventHandler.send( + event = Settings.AppSettings.ThemeSwitched( + theme = AnalyticsParam.AppTheme.fromAppThemeMode(mode), + ), + ) + changeAppThemeMode(mode) + dialogNavigation.dismiss() + } + + fun dismissDialog() { + dialogNavigation.dismiss() } private fun onBiometricAuthenticationToggled(isChecked: Boolean) { @@ -219,17 +224,11 @@ internal class AppSettingsModel @Inject constructor( if (isChecked) { toggleBiometricsAuthentication(enable = true) } else { - updateContentState { - copy( - dialog = dialogsFactory.createDisableBiometricAuthenticationAlert( - onDisable = { - toggleBiometricsAuthentication(enable = false) - dismissDialog() - }, - onDismiss = ::dismissDialog, - ), - ) - } + uiMessageSender.send( + dialogsFactory.createDisableBiometricAuthenticationAlert( + onDisable = { toggleBiometricsAuthentication(enable = false) }, + ), + ) } } @@ -241,25 +240,17 @@ internal class AppSettingsModel @Inject constructor( // TODO : Uncomment and implement analytics event when ready // val param = AnalyticsParam.OnOffState(isChecked) // analyticsEventHandler.send(Settings.AppSettings.RequireAccessCodeChanged(param)) - updateContentState { - copy( - dialog = if (isChecked) { - dialogsFactory.createEnableRequireAccessCodeAlert( - onEnable = { - toggleRequireAccessCode(enable = true) - dismissDialog() - }, - onDismiss = ::dismissDialog, - ) - } else { - dialogsFactory.createDisableRequireAccessCodeAlert( - onDisable = { - toggleRequireAccessCode(enable = false) - dismissDialog() - }, - onDismiss = ::dismissDialog, - ) - }, + if (isChecked) { + uiMessageSender.send( + dialogsFactory.createEnableRequireAccessCodeAlert( + onEnable = { toggleRequireAccessCode(enable = true) }, + ), + ) + } else { + uiMessageSender.send( + dialogsFactory.createDisableRequireAccessCodeAlert( + onDisable = { toggleRequireAccessCode(enable = false) }, + ), ) } } @@ -371,10 +362,6 @@ internal class AppSettingsModel @Inject constructor( } } - private fun dismissDialog() { - updateContentState { copy(dialog = null) } - } - private fun bootstrapLocalState() = modelScope.launch { localState.update { state -> state.copy( @@ -394,15 +381,6 @@ internal class AppSettingsModel @Inject constructor( .launchIn(modelScope) } - private fun updateContentState(block: AppSettingsScreenState.Content.() -> AppSettingsScreenState.Content) { - uiState.update { prevState -> - when (prevState) { - is AppSettingsScreenState.Content -> block(prevState) - is AppSettingsScreenState.Loading -> prevState - } - } - } - private data class LocalState( val hasSecuredWallets: Boolean = false, val isEnrollBiometricsNeeded: Boolean = false, From 1afa4a5d0e84fd563135ace691e7acbabd839664 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Apr 2026 16:38:04 +0500 Subject: [PATCH 026/206] Updated on 2026-08-14 --- .../tangem/data/wallets/DefaultWalletsRepository.kt | 10 ++++++++-- .../domain/wallets/models/WalletSyncResult.kt | 6 ++++++ .../domain/wallets/repository/WalletsRepository.kt | 3 ++- .../wallets/usecase/SyncWalletWithRemoteUseCase.kt | 6 ++++-- .../im/port/model/AddExistingWalletImportModel.kt | 13 +++++++++++-- 5 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/WalletSyncResult.kt diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt index 5a5278632e..f748ce863f 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/DefaultWalletsRepository.kt @@ -24,6 +24,7 @@ import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.models.UserWalletRemoteInfo +import com.tangem.domain.wallets.models.WalletSyncResult import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -118,8 +119,13 @@ internal class DefaultWalletsRepository( } } - override suspend fun createWallet(userWalletId: UserWalletId) { - walletServerBinder.bind(userWalletId) + override suspend fun createWallet(userWalletId: UserWalletId): WalletSyncResult { + val response = walletServerBinder.bind(userWalletId) ?: return WalletSyncResult.AlreadyExists + return if (response is ApiResponse.Success && response.code == HttpException.Code.CREATED) { + WalletSyncResult.Created + } else { + WalletSyncResult.AlreadyExists + } } override fun nftEnabledStatus(userWalletId: UserWalletId): Flow = appPreferencesStore diff --git a/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/WalletSyncResult.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/WalletSyncResult.kt new file mode 100644 index 0000000000..48c7caae72 --- /dev/null +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/WalletSyncResult.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.wallets.models + +enum class WalletSyncResult { + AlreadyExists, + Created, +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt index 2b25388b1e..33686eea83 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/repository/WalletsRepository.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.models.UserWalletRemoteInfo +import com.tangem.domain.wallets.models.WalletSyncResult import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError import kotlinx.coroutines.flow.Flow @@ -22,7 +23,7 @@ interface WalletsRepository { suspend fun setHasWalletsWithRing(userWalletId: UserWalletId) - suspend fun createWallet(userWalletId: UserWalletId) + suspend fun createWallet(userWalletId: UserWalletId): WalletSyncResult fun nftEnabledStatus(userWalletId: UserWalletId): Flow diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt index 063dd3380d..82ebc8e397 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SyncWalletWithRemoteUseCase.kt @@ -1,6 +1,7 @@ package com.tangem.domain.wallets.usecase import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.WalletSyncResult import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger @@ -14,8 +15,9 @@ class SyncWalletWithRemoteUseCase( private val walletsRepository: WalletsRepository, ) { - suspend operator fun invoke(userWalletId: UserWalletId) { - runSuspendCatching { walletsRepository.createWallet(userWalletId) } + suspend operator fun invoke(userWalletId: UserWalletId): WalletSyncResult { + return runSuspendCatching { walletsRepository.createWallet(userWalletId) } .onFailure { TangemLogger.e("Error", it) } + .getOrDefault(WalletSyncResult.AlreadyExists) } } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt index ce2c9cb8f9..6c6efa8ece 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/model/AddExistingWalletImportModel.kt @@ -19,7 +19,9 @@ import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.common.wallets.error.SaveWalletError import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.wallets.builder.HotUserWalletBuilder +import com.tangem.domain.wallets.models.WalletSyncResult import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.domain.wallets.usecase.SyncWalletWithRemoteUseCase import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.hotwallet.MnemonicRepository import com.tangem.features.hotwallet.addexistingwallet.im.port.AddExistingWalletImportComponent @@ -27,6 +29,7 @@ import com.tangem.features.hotwallet.addexistingwallet.im.port.entity.AddExistin import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.HotAuth import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -42,6 +45,7 @@ internal class AddExistingWalletImportModel @Inject constructor( private val tangemHotSdk: TangemHotSdk, private val hotUserWalletBuilderFactory: HotUserWalletBuilder.Factory, private val saveUserWalletUseCase: SaveWalletUseCase, + private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, private val hotWalletFeatureToggles: HotWalletFeatureToggles, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, @@ -114,8 +118,13 @@ internal class AddExistingWalletImportModel @Inject constructor( .onRight { setImportProgress(false) - if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled) { - startAssetsDiscoveryUseCase(userWallet.walletId) + launch(dispatchers.main + NonCancellable) { + val syncResult = syncWalletWithRemoteUseCase(userWallet.walletId) + if (hotWalletFeatureToggles.isAssetsDiscoveryEnabled && + syncResult == WalletSyncResult.Created + ) { + startAssetsDiscoveryUseCase(userWallet.walletId) + } } analyticsEventHandler.send( From 28ed702a51d129bd18c1400739f5e2c58e4583c5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Apr 2026 15:39:21 +0400 Subject: [PATCH 027/206] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 1 + .../com/tangem/common/routing/AppRoute.kt | 1 + .../configs/feature_toggles_config.json | 4 + .../component/ManageTokensSource.kt | 1 + .../featuretoggles/WalletFeatureToggles.kt | 2 + features/wallet/impl/build.gradle.kts | 1 + .../AddAndManageBottomSheetComponent.kt | 67 ++++++++ .../managetokens/di/AddAndManageModule.kt | 20 +++ .../managetokens/model/AddAndManageModel.kt | 83 +++++++++ .../ui/AddAndManageBottomSheetContent.kt | 159 ++++++++++++++++++ .../wallet/child/wallet/WalletComponent.kt | 22 +++ .../intents/WalletContentClickIntents.kt | 9 +- .../DefaultWalletFeatureToggles.kt | 3 + .../preview/WalletScreenPreviewDataLegacy.kt | 6 + .../router/DefaultWalletRouter.kt | 11 +- .../presentation/router/InnerWalletRouter.kt | 9 +- .../wallet/state/model/WalletDialogConfig.kt | 3 + .../state/model/WalletTokensListState.kt | 7 +- .../transformers/SetTokenListTransformer.kt | 3 + .../converter/TokenListStateConverter.kt | 17 ++ .../converter/WalletTokensListUMConverter.kt | 15 +- .../subscribers/AccountListSubscriber.kt | 5 + .../subscribers/BasicAccountListSubscriber.kt | 3 + .../subscribers/SingleWalletSubscriber.kt | 5 + .../SingleWalletWithTokenSubscriberLegacy.kt | 5 + .../presentation/wallet/ui/WalletScreen.kt | 18 +- .../MultiCurrencyOrganizeButton.kt | 13 +- 27 files changed, 468 insertions(+), 25 deletions(-) create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/ui/AddAndManageBottomSheetContent.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index a200fea92f..afff976cd4 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -138,6 +138,7 @@ internal class ChildFactory @Inject constructor( AppRoute.ManageTokens.Source.SETTINGS -> ManageTokensSource.SETTINGS AppRoute.ManageTokens.Source.STORIES -> ManageTokensSource.STORIES AppRoute.ManageTokens.Source.ACCOUNT -> ManageTokensSource.ACCOUNT + AppRoute.ManageTokens.Source.WALLET -> ManageTokensSource.WALLET } val mode = route.accountId?.let { ManageTokensMode.Account(it) } ?: ManageTokensMode.None diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index ea5ec5fdcc..6fba5a36d5 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -146,6 +146,7 @@ sealed class AppRoute(val path: String) : Route { STORIES, SETTINGS, ACCOUNT, + WALLET, } } diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 0154a6d8a0..5efc71652f 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -79,5 +79,9 @@ { "name": "SOLANA_TX_HISTORY_ENABLED", "version": "undefined" + }, + { + "name": "ADD_AND_MANAGE_TOKENS_ENABLED", + "version": "undefined" } ] diff --git a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt index fcce6daea7..dc51c35411 100644 --- a/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt +++ b/features/manage-tokens/api/src/main/kotlin/com/tangem/features/managetokens/component/ManageTokensSource.kt @@ -9,6 +9,7 @@ enum class ManageTokensSource(val analyticsName: String) { ONBOARDING(analyticsName = "Onboarding"), SETTINGS(analyticsName = "Wallet Settings"), ACCOUNT(analyticsName = "Account"), + WALLET(analyticsName = "Wallet"), SEND_VIA_SWAP(analyticsName = "SendViaSwap"), } diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt index 1bb167f64c..1b13fbb3ad 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt @@ -10,4 +10,6 @@ interface WalletFeatureToggles { val isWalletReorderFeatureEnabled: Boolean val isMainScreenQrScanningEnabled: Boolean + + val isAddAndManageTokensEnabled: Boolean } \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 70e66ddf64..c91c083d7d 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -128,6 +128,7 @@ dependencies { implementation(projects.domain.assetsdiscovery) /** Feature Apis */ + implementation(projects.features.account.api) implementation(projects.features.details.api) implementation(projects.features.hotWallet.api) implementation(projects.features.manageTokens.api) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt new file mode 100644 index 0000000000..3cd335e5c5 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt @@ -0,0 +1,67 @@ +package com.tangem.feature.wallet.child.managetokens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.wallet.child.managetokens.model.AddAndManageModel +import com.tangem.feature.wallet.child.managetokens.ui.AddAndManageBottomSheetContent +import com.tangem.features.account.PortfolioSelectorComponent +import kotlinx.serialization.builtins.serializer + +internal class AddAndManageBottomSheetComponent( + appComponentContext: AppComponentContext, + private val params: Params, + private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: AddAndManageModel = getOrCreateModel(params) + + private val portfolioSelectorSlot = childSlot( + source = model.portfolioSelectorNavigation, + serializer = Unit.serializer(), + handleBackButton = false, + childFactory = { _, context -> portfolioSelectorChild(context) }, + ) + + private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent = + portfolioSelectorComponentFactory.create( + context = childByContext(componentContext), + params = PortfolioSelectorComponent.Params( + portfolioFetcher = model.portfolioFetcher, + controller = model.portfolioSelectorController, + bsCallback = model.portfolioSelectorCallback, + ), + ) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val portfolioSelectorSlot by portfolioSelectorSlot.subscribeAsState() + + AddAndManageBottomSheetContent( + onAddTokensClick = model::onAddTokensClick, + onOrganizeTokensClick = model::onOrganizeTokensClick, + onDismiss = ::dismiss, + ) + + portfolioSelectorSlot.child?.instance?.BottomSheet() + } + + data class Params( + val userWalletId: UserWalletId, + val onDismiss: () -> Unit, + val onOrganizeTokensClick: () -> Unit, + val onManageTokensClick: (AccountId) -> Unit, + ) +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt new file mode 100644 index 0000000000..5ce1fdfab7 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/di/AddAndManageModule.kt @@ -0,0 +1,20 @@ +package com.tangem.feature.wallet.child.managetokens.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.feature.wallet.child.managetokens.model.AddAndManageModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AddAndManageModule { + + @Binds + @IntoMap + @ClassKey(AddAndManageModel::class) + fun bindAddAndManageModel(model: AddAndManageModel): Model +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt new file mode 100644 index 0000000000..13df43dd76 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt @@ -0,0 +1,83 @@ +package com.tangem.feature.wallet.child.managetokens.model + +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.domain.models.account.AccountId +import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent +import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.account.PortfolioSelectorController +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ModelScoped +internal class AddAndManageModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val portfolioFetcherFactory: PortfolioFetcher.Factory, + val portfolioSelectorController: PortfolioSelectorController, +) : Model() { + + private val params = paramsContainer.require() + + val portfolioSelectorNavigation: SlotNavigation = SlotNavigation() + + val portfolioFetcher: PortfolioFetcher by lazy { + portfolioFetcherFactory.create( + mode = PortfolioFetcher.Mode.Wallet(params.userWalletId), + scope = modelScope, + ) + } + + val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback { + override val onDismiss: () -> Unit = { portfolioSelectorNavigation.dismiss() } + override val onBack: () -> Unit = { portfolioSelectorNavigation.dismiss() } + } + + init { + observeAccountSelection() + } + + fun onAddTokensClick() { + modelScope.launch { + val data = portfolioFetcher.data.first() + val isSingleAccount = data.isSingleChoice(params.userWalletId) + + if (isSingleAccount) { + val mainAccountId = data.balances[params.userWalletId] + ?.accountsBalance + ?.mainAccount + ?.accountId + ?: AccountId.forMainCryptoPortfolio(params.userWalletId) + + params.onDismiss() + params.onManageTokensClick(mainAccountId) + } else { + portfolioSelectorNavigation.activate(Unit) + } + } + } + + fun onOrganizeTokensClick() { + params.onDismiss() + params.onOrganizeTokensClick() + } + + private fun observeAccountSelection() { + modelScope.launch { + portfolioSelectorController.selectedAccount.collect { accountId -> + if (accountId != null) { + portfolioSelectorNavigation.dismiss() + params.onDismiss() + params.onManageTokensClick(accountId) + } + } + } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/ui/AddAndManageBottomSheetContent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/ui/AddAndManageBottomSheetContent.kt new file mode 100644 index 0000000000..5534c0cb4f --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/ui/AddAndManageBottomSheetContent.kt @@ -0,0 +1,159 @@ +package com.tangem.feature.wallet.child.managetokens.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +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.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.res.R as ResR +import com.tangem.core.ui.R +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +internal fun AddAndManageBottomSheetContent( + onAddTokensClick: () -> Unit, + onOrganizeTokensClick: () -> Unit, + onDismiss: () -> Unit, +) { + val config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = AddAndManageBottomSheetConfigContent, + ) + + TangemModalBottomSheet( + config = config, + containerColor = TangemTheme.colors.background.primary, + title = { + TangemModalBottomSheetTitle( + title = resourceReference(ResR.string.main_add_and_manage_tokens), + endIconRes = R.drawable.ic_close_24, + onEndClick = onDismiss, + ) + }, + content = { + AddAndManageContent( + onAddTokensClick = onAddTokensClick, + onOrganizeTokensClick = onOrganizeTokensClick, + ) + }, + ) +} + +@Composable +private fun AddAndManageContent(onAddTokensClick: () -> Unit, onOrganizeTokensClick: () -> Unit) { + Column( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + AddAndManageRow( + iconRes = R.drawable.ic_plus_24, + title = ResR.string.add_and_manage_sheet_manage_title, + subtitle = ResR.string.add_and_manage_sheet_manage_subtitle, + onClick = onAddTokensClick, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = 0, + lastIndex = 1, + addDefaultPadding = false, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + AddAndManageRow( + iconRes = R.drawable.ic_filter_default_24, + title = ResR.string.add_and_manage_sheet_organize_title, + subtitle = ResR.string.add_and_manage_sheet_organize_subtitle, + onClick = onOrganizeTokensClick, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = 1, + lastIndex = 1, + addDefaultPadding = false, + backgroundColor = TangemTheme.colors.background.action, + ), + ) + } +} + +@Composable +private fun AddAndManageRow( + iconRes: Int, + title: Int, + subtitle: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f)), + ) { + Icon( + modifier = Modifier.size(18.dp), + painter = rememberVectorPainter(ImageVector.vectorResource(id = iconRes)), + tint = TangemTheme.colors.icon.accent, + contentDescription = null, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResourceSafe(id = title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = stringResourceSafe(id = subtitle), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + ) + } + } +} + +private object AddAndManageBottomSheetConfigContent : TangemBottomSheetConfigContent + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun AddAndManageBottomSheetContent_Preview() { + TangemThemePreview { + AddAndManageContent( + onAddTokensClick = {}, + onOrganizeTokensClick = {}, + ) + } +} +// endregion \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index f15fbdfb75..72e1976879 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -23,7 +23,9 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableDialogComponent import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.tokens.model.details.TokenAction +import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent +import com.tangem.features.account.PortfolioSelectorComponent import com.tangem.feature.wallet.child.tokenActions.DefaultTokenActionsComponent import com.tangem.feature.wallet.child.tokenActions.TokenActionsComponent import com.tangem.feature.wallet.child.wallet.model.WalletModel @@ -64,6 +66,7 @@ internal class WalletComponent @AssistedInject constructor( private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles, private val networkSelectionComponentFactory: NetworkSelectionComponent.Factory, private val tokenActionsComponentFactory: TokenActionsComponent.Factory, + private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, private val designFeatureToggles: DesignFeatureToggles, ) : ComposableContentComponent, AppComponentContext by appComponentContext { @@ -178,6 +181,25 @@ internal class WalletComponent @AssistedInject constructor( ), ) } + is WalletDialogConfig.AddAndManage -> { + AddAndManageBottomSheetComponent( + appComponentContext = childByContext(componentContext), + params = AddAndManageBottomSheetComponent.Params( + userWalletId = dialogConfig.userWalletId, + onDismiss = model.innerWalletRouter.dialogNavigation::dismiss, + onOrganizeTokensClick = { + model.innerWalletRouter.openOrganizeTokensScreen(dialogConfig.userWalletId) + }, + onManageTokensClick = { accountId -> + model.innerWalletRouter.openManageTokensScreen( + accountId = accountId, + source = AppRoute.ManageTokens.Source.WALLET, + ) + }, + ), + portfolioSelectorComponentFactory = portfolioSelectorComponentFactory, + ) + } is WalletDialogConfig.OrganizeTokens -> { OrganizeTokensComponent( appComponentContext = childByContext(componentContext), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 8f880da28c..71220bf8a9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -36,6 +36,7 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBot import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.MultiWalletCurrencyActionsConverter import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.collectLatest @@ -112,6 +113,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val yieldSupplySetShouldShowMainPromoUseCase: YieldSupplySetShouldShowMainPromoUseCase, private val tokenListAnalyticsSender: TokenListAnalyticsSender, private val uiMessageSender: UiMessageSender, + private val walletFeatureToggles: WalletFeatureToggles, ) : BaseWalletClickIntents(), WalletContentClickIntents { override fun onDetailsClick() { @@ -119,7 +121,12 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( } override fun onOrganizeTokensClick() { - router.openOrganizeTokensScreen(userWalletId = stateHolder.getSelectedWalletId()) + val userWalletId = stateHolder.getSelectedWalletId() + if (walletFeatureToggles.isAddAndManageTokensEnabled) { + router.openAddAndManageBottomSheet(userWalletId = userWalletId) + } else { + router.openOrganizeTokensScreen(userWalletId = userWalletId) + } } override fun onDismissMarketsTooltip() { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt index d1ee8ec418..0120f51987 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt @@ -14,4 +14,7 @@ internal class DefaultWalletFeatureToggles @Inject constructor( override val isMainScreenQrScanningEnabled: Boolean get() = featureToggles.isFeatureEnabled(FeatureToggles.MAIN_SCREEN_QR_SCANNING_ENABLED) + + override val isAddAndManageTokensEnabled: Boolean + get() = featureToggles.isFeatureEnabled(FeatureToggles.ADD_AND_MANAGE_TOKENS_ENABLED) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt index c52c371251..bbdd063032 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt @@ -91,6 +91,8 @@ internal object WalletScreenPreviewDataLegacy { ), ), organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( + textRes = R.string.organize_tokens_title, + iconRes = R.drawable.ic_filter_24, isEnabled = true, onClick = {}, ), @@ -119,6 +121,8 @@ internal object WalletScreenPreviewDataLegacy { ), ), organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( + textRes = R.string.organize_tokens_title, + iconRes = R.drawable.ic_filter_24, isEnabled = true, onClick = {}, ), @@ -149,6 +153,8 @@ internal object WalletScreenPreviewDataLegacy { ), ), organizeTokensButtonConfig = WalletTokensListState.OrganizeTokensButtonConfig( + textRes = R.string.organize_tokens_title, + iconRes = R.drawable.ic_filter_24, isEnabled = true, onClick = {}, ), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt index 1eadd2b07b..962bd6549c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/DefaultWalletRouter.kt @@ -32,6 +32,7 @@ import javax.inject.Inject /** Default implementation of wallet feature router */ @ModelScoped +@Suppress("TooManyFunctions") internal class DefaultWalletRouter @Inject constructor( private val router: AppRouter, private val urlOpener: UrlOpener, @@ -66,14 +67,20 @@ internal class DefaultWalletRouter @Inject constructor( ) } - override fun openManageTokensScreen(accountId: AccountId) { + override fun openManageTokensScreen(accountId: AccountId, source: AppRoute.ManageTokens.Source) { val route = AppRoute.ManageTokens( - source = AppRoute.ManageTokens.Source.ACCOUNT, + source = source, accountId = accountId, ) router.push(route) } + override fun openAddAndManageBottomSheet(userWalletId: UserWalletId) { + dialogNavigation.activate( + configuration = WalletDialogConfig.AddAndManage(userWalletId = userWalletId), + ) + } + override fun openOnboardingScreen(scanResponse: ScanResponse, continueBackup: Boolean) { router.push( AppRoute.Onboarding( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt index 7d772e539c..597093c071 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/router/InnerWalletRouter.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.SharedFlow [REDACTED_AUTHOR] */ @Stable +@Suppress("TooManyFunctions") internal interface InnerWalletRouter { val dialogNavigation: SlotNavigation @@ -47,7 +48,13 @@ internal interface InnerWalletRouter { fun openDetailsScreen(selectedWalletId: UserWalletId) /** Open manage tokens screen */ - fun openManageTokensScreen(accountId: AccountId) + fun openManageTokensScreen( + accountId: AccountId, + source: AppRoute.ManageTokens.Source = AppRoute.ManageTokens.Source.ACCOUNT, + ) + + /** Open add and manage tokens bottom sheet */ + fun openAddAndManageBottomSheet(userWalletId: UserWalletId) /** Open onboarding screen */ fun openOnboardingScreen(scanResponse: ScanResponse, continueBackup: Boolean = false) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt index 8322d1c8df..52cb3834c7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletDialogConfig.kt @@ -50,6 +50,9 @@ internal sealed interface WalletDialogConfig { @Serializable data class KycRejected(val walletId: UserWalletId, val customerId: String) : WalletDialogConfig + @Serializable + data class AddAndManage(val userWalletId: UserWalletId) : WalletDialogConfig + @Serializable data class OrganizeTokens(val userWalletId: UserWalletId) : WalletDialogConfig diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt index 82050bd5fb..69cb21f25d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletTokensListState.kt @@ -49,5 +49,10 @@ internal sealed class WalletTokensListState { } } - data class OrganizeTokensButtonConfig(val isEnabled: Boolean, val onClick: () -> Unit) + data class OrganizeTokensButtonConfig( + val textRes: Int, + val iconRes: Int, + val isEnabled: Boolean, + val onClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt index f3d9013ff0..ef84cc13c6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt @@ -25,6 +25,7 @@ internal class SetTokenListTransformer( private val shouldShowMainPromo: Boolean, private val isAccountsModeEnabled: Boolean, private val isRedesignEnabled: Boolean, + private val isAddAndManageTokensEnabled: Boolean, ) : WalletStateTransformer(userWallet.walletId) { private val tangemPayConverter by lazy { @@ -105,6 +106,7 @@ internal class SetTokenListTransformer( yieldModuleApyMap = yieldSupplyApyMap, stakingAvailabilityMap = stakingAvailabilityMap, shouldShowMainPromo = shouldShowMainPromo, + isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ).convert(value = this) } @@ -137,6 +139,7 @@ internal class SetTokenListTransformer( shouldShowMainPromo = shouldShowMainPromo, isAccountsModeEnabled = isAccountsModeEnabled, expandedAccounts = params.expandedAccounts, + isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ).convert(value = params.accountList) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 926fdead7a..8d23a44a9c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -41,6 +41,7 @@ internal class TokenListStateConverter( private val yieldModuleApyMap: Map, private val stakingAvailabilityMap: Map, private val shouldShowMainPromo: Boolean, + private val isAddAndManageTokensEnabled: Boolean, ) : Converter { private val yieldSupplyPromoBannerConverter = YieldSupplyPromoBannerConverter( @@ -157,6 +158,8 @@ internal class TokenListStateConverter( } return if (currenciesSize > 1 && !isSingleCurrencyWalletWithToken()) { WalletOrganizeTokensButtonConfig( + textRes = organizeButtonTextRes(), + iconRes = organizeButtonIconRes(), isEnabled = tokenList.totalFiatBalance !is TotalFiatBalance.Loading, onClick = clickIntents::onOrganizeTokensClick, ) @@ -168,6 +171,8 @@ internal class TokenListStateConverter( private fun getOrganizeTokensButtonStateV2(accountList: AccountStatusList): WalletOrganizeTokensButtonConfig? { return if (accountList.flattenCurrencies().size > 1 && !isSingleCurrencyWalletWithToken()) { WalletOrganizeTokensButtonConfig( + textRes = organizeButtonTextRes(), + iconRes = organizeButtonIconRes(), isEnabled = accountList.totalFiatBalance !is TotalFiatBalance.Loading, onClick = clickIntents::onOrganizeTokensClick, ) @@ -176,6 +181,18 @@ internal class TokenListStateConverter( } } + private fun organizeButtonTextRes(): Int = if (isAddAndManageTokensEnabled) { + R.string.main_add_and_manage_tokens + } else { + R.string.organize_tokens_title + } + + private fun organizeButtonIconRes(): Int = if (isAddAndManageTokensEnabled) { + R.drawable.ic_filter_default_24 + } else { + R.drawable.ic_filter_24 + } + private fun isSingleCurrencyWalletWithToken(): Boolean { return selectedWallet is UserWallet.Cold && selectedWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt index c252326e14..a5535584a4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt @@ -38,6 +38,7 @@ internal class WalletTokensListUMConverter( private val isAccountsModeEnabled: Boolean, private val expandedAccounts: Set, private val stakingAvailabilityMap: Map, + private val isAddAndManageTokensEnabled: Boolean, shouldShowMainPromo: Boolean, ) : Converter { @@ -164,15 +165,25 @@ internal class WalletTokensListUMConverter( } private fun getOrganizeButtonUM(accountList: AccountStatusList): TangemButtonUM? { + val textRes = if (isAddAndManageTokensEnabled) { + R.string.main_add_and_manage_tokens + } else { + R.string.organize_tokens_title + } + val iconRes = if (isAddAndManageTokensEnabled) { + R.drawable.ic_filter_default_24 + } else { + R.drawable.ic_filter_24 + } return if (accountList.flattenCurrencies().size > 1 && !selectedWallet.isSingleWalletWithToken()) { TangemButtonUM( - text = resourceReference(R.string.organize_tokens_title), + text = resourceReference(textRes), isEnabled = accountList.totalFiatBalance !is TotalFiatBalance.Loading, size = TangemButtonSize.X9, shape = TangemButtonShape.Rounded, type = TangemButtonType.PrimaryInverse, tangemIconUM = TangemIconUM.Icon( - iconRes = R.drawable.ic_filter_default_24, + iconRes = iconRes, tintReference = { if (accountList.totalFiatBalance !is TotalFiatBalance.Loading) { TangemTheme.colors2.graphic.neutral.primary diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt index 2d7ba38899..a440c34a7f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt @@ -12,6 +12,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoU import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.coroutines.combine7 import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -39,8 +40,12 @@ internal class AccountListSubscriber @AssistedInject constructor( private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase, private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase, private val designFeatureToggles: DesignFeatureToggles, + private val walletFeatureToggles: WalletFeatureToggles, ) : BasicAccountListSubscriber() { + override val isAddAndManageTokensEnabled: Boolean + get() = walletFeatureToggles.isAddAndManageTokensEnabled + override fun create(coroutineScope: CoroutineScope): Flow<*> = combine7( flow1 = getAccountStatusListFlow(), flow2 = getAppCurrencyFlow(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt index 6192849dc4..047b5d765d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt @@ -34,6 +34,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { abstract val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase abstract val stateController: WalletStateController abstract val clickIntents: WalletClickIntents + abstract val isAddAndManageTokensEnabled: Boolean override val singleAccountStatusListSupplier: SingleAccountStatusListSupplier get() = accountDependencies.singleAccountStatusListSupplier @@ -105,6 +106,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { shouldShowMainPromo = shouldShowMainPromo, isAccountsModeEnabled = isAccountMode, isRedesignEnabled = true, + isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ), ) } @@ -168,6 +170,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { shouldShowMainPromo = shouldShowMainPromo, isAccountsModeEnabled = false, isRedesignEnabled = false, + isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ), ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt index 88e0697c7e..ca409e79c6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt @@ -5,6 +5,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,8 +19,12 @@ internal class SingleWalletSubscriber @AssistedInject constructor( override val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, override val stateController: WalletStateController, override val clickIntents: WalletClickIntents, + private val walletFeatureToggles: WalletFeatureToggles, ) : BasicAccountListSubscriber() { + override val isAddAndManageTokensEnabled: Boolean + get() = walletFeatureToggles.isAddAndManageTokensEnabled + override fun create(coroutineScope: CoroutineScope): Flow = combine( flow = getAccountStatusListFlow(), flow2 = getAppCurrencyFlow(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt index 1a31f1f1b4..0ad3c442ba 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt @@ -5,6 +5,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -19,8 +20,12 @@ internal class SingleWalletWithTokenSubscriberLegacy @AssistedInject constructor override val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, override val stateController: WalletStateController, override val clickIntents: WalletClickIntents, + private val walletFeatureToggles: WalletFeatureToggles, ) : BasicAccountListSubscriber() { + override val isAddAndManageTokensEnabled: Boolean + get() = walletFeatureToggles.isAddAndManageTokensEnabled + override fun create(coroutineScope: CoroutineScope): Flow = combine( flow = getAccountStatusListFlow(), flow2 = getAppCurrencyFlow(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 8f842b82cf..1dcf79012d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -726,17 +726,13 @@ private fun WalletSnackbarHost( } internal fun LazyListScope.organizeTokens(state: WalletState, itemModifier: Modifier) { - (state as? WalletState.MultiCurrency)?.let { - (state.tokensListState as? WalletTokensListState.ContentState)?.let { - it.organizeTokensButtonConfig?.let { config -> - organizeTokensButton( - modifier = itemModifier, - isEnabled = config.isEnabled, - onClick = config.onClick, - ) - } - } - } + val multiCurrencyState = state as? WalletState.MultiCurrency ?: return + val contentState = multiCurrencyState.tokensListState as? WalletTokensListState.ContentState ?: return + val config = contentState.organizeTokensButtonConfig ?: return + organizeTokensButton( + modifier = itemModifier, + config = config, + ) } internal fun LazyListScope.nftCollections(state: WalletState, itemModifier: Modifier) { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt index 94592fc246..6e5177817c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt @@ -7,7 +7,7 @@ import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.buttons.actions.RoundedActionButton import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.test.MainScreenTestTags -import com.tangem.feature.wallet.impl.R +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState private const val ORGANIZE_BUTTON_CONTENT_TYPE = "OrganizeTokensButton" @@ -20,18 +20,17 @@ private const val ORGANIZE_BUTTON_CONTENT_TYPE = "OrganizeTokensButton" [REDACTED_AUTHOR] */ internal fun LazyListScope.organizeTokensButton( - isEnabled: Boolean, - onClick: () -> Unit, + config: WalletTokensListState.OrganizeTokensButtonConfig, modifier: Modifier = Modifier, ) { item(key = ORGANIZE_BUTTON_CONTENT_TYPE, contentType = ORGANIZE_BUTTON_CONTENT_TYPE) { RoundedActionButton( modifier = modifier.testTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON), config = ActionButtonConfig( - text = resourceReference(id = R.string.organize_tokens_title), - iconResId = R.drawable.ic_filter_24, - onClick = onClick, - isEnabled = isEnabled, + text = resourceReference(id = config.textRes), + iconResId = config.iconRes, + onClick = config.onClick, + isEnabled = config.isEnabled, ), ) } From 27e9400b2aa2a85e21cb32148181fd127a622916 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Apr 2026 15:39:33 +0400 Subject: [PATCH 028/206] Updated on 2026-08-14 --- .../feedback/DefaultFeedbackRepository.kt | 29 +++++++------------ .../tangem/data/feedback/di/FeedbackModule.kt | 3 -- .../data/staking/DefaultStakeKitRepository.kt | 12 +++++--- .../DefaultTransactionRepository.kt | 10 +++---- .../feedback/models/BlockchainErrorInfo.kt | 8 ++--- .../feedback/repository/FeedbackRepository.kt | 7 ++--- .../utils/EmailMessageBodyResolver.kt | 20 +++++-------- ...GetConstructedStakingTransactionUseCase.kt | 5 ++-- .../repositories/StakeKitRepository.kt | 4 +-- .../v2/send/confirm/model/SendConfirmModel.kt | 5 ++-- .../features/send/v2/send/model/SendModel.kt | 7 ++--- .../confirm/model/NFTSendConfirmModel.kt | 9 +++--- .../send/v2/sendnft/model/NFTSendModel.kt | 5 ++-- .../impl/presentation/model/StakingModel.kt | 18 +++++------- .../helpers/StakeKitTransactionSender.kt | 4 +-- .../swap/v2/impl/common/SwapAlertFactory.kt | 3 +- .../tangem/feature/swap/model/SwapModel.kt | 3 +- .../impl/common/YieldSupplyAlertFactory.kt | 3 +- 18 files changed, 67 insertions(+), 88 deletions(-) diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt index 5f39f9f870..76ef2e52f0 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt @@ -1,7 +1,7 @@ package com.tangem.data.feedback import android.os.Build -import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.core.navigation.email.EmailSender import com.tangem.data.feedback.converters.BlockchainInfoConverter import com.tangem.data.feedback.converters.WalletMetaInfoConverter @@ -10,10 +10,10 @@ import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.feedback.models.* import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.utils.logging.TangemLogger import com.tangem.utils.version.AppVersionProvider import kotlinx.coroutines.flow.MutableStateFlow @@ -23,23 +23,20 @@ import java.io.File /** * Implementation of [FeedbackRepository] * - * @property appLogsStore app logs store - * @property userWalletsListManager user wallets list manager - * @property userWalletsListRepository user wallets repository - * @property walletManagersStore wallet managers store - * @property emailSender email sender - * @property appVersionProvider app version provider + * @property appLogsStore app logs store + * @property userWalletsListRepository repository for getting user wallets + * @property walletManagersStore wallet managers store + * @property emailSender email sender + * @property appVersionProvider app version provider * [REDACTED_AUTHOR] */ -@Suppress("LongParameterList") internal class DefaultFeedbackRepository( private val appLogsStore: AppLogsStore, private val userWalletsListRepository: UserWalletsListRepository, private val walletManagersStore: WalletManagersStore, private val emailSender: EmailSender, private val appVersionProvider: AppVersionProvider, - private val getSelectedWalletUseCase: GetSelectedWalletUseCase, ) : FeedbackRepository { private val blockchainsErrors = MutableStateFlow>(emptyMap()) @@ -68,16 +65,12 @@ internal class DefaultFeedbackRepository( .map(BlockchainInfoConverter::convert) } - override suspend fun getBlockchainInfo( - userWalletId: UserWalletId, - blockchainId: String, - derivationPath: String?, - ): BlockchainInfo? { + override suspend fun getBlockchainInfo(userWalletId: UserWalletId, networkId: Network.ID): BlockchainInfo? { return walletManagersStore .getSyncOrNull( userWalletId = userWalletId, - blockchain = Blockchain.fromId(blockchainId), - derivationPath = derivationPath, + blockchain = networkId.toBlockchain(), + derivationPath = networkId.derivationPath.value, ) ?.let(BlockchainInfoConverter::convert) } @@ -91,7 +84,7 @@ internal class DefaultFeedbackRepository( } override fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) { - val userWallet = getSelectedWalletUseCase.sync().getOrNull() ?: error("UserWallet is not selected") + val userWallet = userWalletsListRepository.selectedUserWallet.value ?: error("UserWallet is not selected") blockchainsErrors.update { map -> map.toMutableMap().apply { diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt index db803a21d2..6d4d9176c1 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt @@ -9,7 +9,6 @@ import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.feedback.repository.FeedbackRepository -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides @@ -29,7 +28,6 @@ internal object FeedbackModule { walletManagersStore: WalletManagersStore, emailSender: EmailSender, appVersionProvider: AppVersionProvider, - getSelectedWalletUseCase: GetSelectedWalletUseCase, ): FeedbackRepository { return DefaultFeedbackRepository( appLogsStore = appLogsStore, @@ -37,7 +35,6 @@ internal object FeedbackModule { emailSender = emailSender, appVersionProvider = appVersionProvider, userWalletsListRepository = userWalletsListRepository, - getSelectedWalletUseCase = getSelectedWalletUseCase, ) } diff --git a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt index 8f0fd77aec..335d1a5dac 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/DefaultStakeKitRepository.kt @@ -8,6 +8,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionStatus import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchainsdk.utils.fromNetworkId +import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.extensions.hexToBytes import com.tangem.common.extensions.toCompressedPublicKey import com.tangem.data.staking.converters.YieldConverter @@ -244,7 +245,7 @@ internal class DefaultStakeKitRepository( } override suspend fun constructTransaction( - networkId: String, + networkId: Network.RawID, fee: Fee, amount: Amount, transactionId: String, @@ -320,8 +321,11 @@ internal class DefaultStakeKitRepository( } } - private fun getTransactionDataType(networkId: String, unsignedTransaction: String): TransactionData.Compiled.Data { - return when (Blockchain.fromId(networkId)) { + private fun getTransactionDataType( + networkId: Network.RawID, + unsignedTransaction: String, + ): TransactionData.Compiled.Data { + return when (val blockchain = networkId.toBlockchain()) { Blockchain.Solana, Blockchain.Cosmos, -> TransactionData.Compiled.Data.Bytes(unsignedTransaction.hexToBytes()) @@ -335,7 +339,7 @@ internal class DefaultStakeKitRepository( ?: error("Failed to parse Tron StakeKit transaction") TransactionData.Compiled.Data.RawString(tronStakeKitTransaction.rawDataHex) } - else -> error("Unsupported blockchain") + else -> error("Unsupported blockchain: $blockchain") } } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt index 17c0b17828..ae96776efa 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultTransactionRepository.kt @@ -61,7 +61,7 @@ internal class DefaultTransactionRepository( derivationPath = network.derivationPath.value, ) ?: error("Wallet manager not found") - val extras = txExtras ?: getMemoExtras(networkId = network.rawId, memo) + val extras = txExtras ?: getMemoExtras(networkId = network.id.rawId, memo) val patchedDestination = if (amount.type is AmountType.TokenYieldSupply) { walletManager.getYieldModuleAddress() @@ -128,7 +128,7 @@ internal class DefaultTransactionRepository( destination = destination, userWalletId = userWalletId, network = network, - txExtras = getMemoExtras(networkId = network.rawId, memo = memo) ?: extras, + txExtras = getMemoExtras(networkId = network.id.rawId, memo = memo) ?: extras, ) } @@ -262,7 +262,7 @@ internal class DefaultTransactionRepository( fee = fee ?: Fee.Common(amount = amount), destination = destination, ).copy( - extras = getMemoExtras(networkId = network.rawId, memo = memo), + extras = getMemoExtras(networkId = network.id.rawId, memo = memo), ) validator.validate(transactionData = transactionData) @@ -332,8 +332,8 @@ internal class DefaultTransactionRepository( } @Suppress("CyclomaticComplexMethod") - private fun getMemoExtras(networkId: String, memo: String?): TransactionExtras? { - val blockchain = Blockchain.fromId(networkId) + private fun getMemoExtras(networkId: Network.RawID, memo: String?): TransactionExtras? { + val blockchain = networkId.toBlockchain() if (memo == null) return null return when (blockchain) { Blockchain.Stellar -> { diff --git a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/BlockchainErrorInfo.kt b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/BlockchainErrorInfo.kt index fc76ba451b..bede31c929 100644 --- a/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/BlockchainErrorInfo.kt +++ b/domain/feedback/models/src/main/kotlin/com/tangem/domain/feedback/models/BlockchainErrorInfo.kt @@ -1,11 +1,12 @@ package com.tangem.domain.feedback.models +import com.tangem.domain.models.network.Network + /** * Information about blockchain's operation error * * @property errorMessage message about error - * @property blockchainId blockchain id - * @property derivationPath derivation path + * @property networkId network ID * @property destinationAddress destination address * @property tokenSymbol token symbol or null, if it isn't operation with token * @property amount amount @@ -13,8 +14,7 @@ package com.tangem.domain.feedback.models */ data class BlockchainErrorInfo( val errorMessage: String, - val blockchainId: String, - val derivationPath: String?, + val networkId: Network.ID?, val destinationAddress: String, val tokenSymbol: String?, val amount: String, diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt index 3661f00b48..0f3bb871b5 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/repository/FeedbackRepository.kt @@ -1,6 +1,7 @@ package com.tangem.domain.feedback.repository import com.tangem.domain.feedback.models.* +import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import java.io.File @@ -17,11 +18,7 @@ interface FeedbackRepository { fun getPhoneInfo(): PhoneInfo - suspend fun getBlockchainInfo( - userWalletId: UserWalletId, - blockchainId: String, - derivationPath: String?, - ): BlockchainInfo? + suspend fun getBlockchainInfo(userWalletId: UserWalletId, networkId: Network.ID): BlockchainInfo? fun saveBlockchainErrorInfo(error: BlockchainErrorInfo) diff --git a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt index 0d16f8c9c2..112112773f 100644 --- a/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt +++ b/domain/feedback/src/main/java/com/tangem/domain/feedback/utils/EmailMessageBodyResolver.kt @@ -94,11 +94,10 @@ class EmailMessageBodyResolver( val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) - val blockchainInfo = blockchainError?.let { + val blockchainInfo = blockchainError?.networkId?.let { networkId -> feedbackRepository.getBlockchainInfo( userWalletId = userWalletId, - blockchainId = blockchainError.blockchainId, - derivationPath = blockchainError.derivationPath, + networkId = networkId, ) } @@ -159,11 +158,10 @@ class EmailMessageBodyResolver( val userWalletId = requireNotNull(walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) - val blockchainInfo = blockchainError?.let { + val blockchainInfo = blockchainError?.networkId?.let { networkId -> feedbackRepository.getBlockchainInfo( userWalletId = userWalletId, - blockchainId = blockchainError.blockchainId, - derivationPath = blockchainError.derivationPath, + networkId = networkId, ) } @@ -181,11 +179,10 @@ class EmailMessageBodyResolver( val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) - val blockchainInfo = blockchainError?.let { + val blockchainInfo = blockchainError?.networkId?.let { networkId -> feedbackRepository.getBlockchainInfo( userWalletId = userWalletId, - blockchainId = blockchainError.blockchainId, - derivationPath = blockchainError.derivationPath, + networkId = networkId, ) } @@ -210,11 +207,10 @@ class EmailMessageBodyResolver( val userWalletId = requireNotNull(type.walletMetaInfo.userWalletId) { "UserWalletId must be not null" } val blockchainError = feedbackRepository.getBlockchainErrorInfo(userWalletId = userWalletId) - val blockchainInfo = blockchainError?.let { + val blockchainInfo = blockchainError?.networkId?.let { networkId -> feedbackRepository.getBlockchainInfo( userWalletId = userWalletId, - blockchainId = blockchainError.blockchainId, - derivationPath = blockchainError.derivationPath, + networkId = networkId, ) } diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt index 7242d70f51..d9f06df359 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/GetConstructedStakingTransactionUseCase.kt @@ -4,10 +4,11 @@ import arrow.core.Either import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.network.Network import com.tangem.domain.staking.model.stakekit.StakingError import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction -import com.tangem.domain.staking.repositories.StakingErrorResolver import com.tangem.domain.staking.repositories.StakeKitRepository +import com.tangem.domain.staking.repositories.StakingErrorResolver class GetConstructedStakingTransactionUseCase( private val stakeKitRepository: StakeKitRepository, @@ -15,7 +16,7 @@ class GetConstructedStakingTransactionUseCase( ) { suspend operator fun invoke( - networkId: String, + networkId: Network.RawID, fee: Fee, amount: Amount, transactionId: String, diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt index e614a685bc..876e026c8b 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/repositories/StakeKitRepository.kt @@ -5,11 +5,11 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network +import com.tangem.domain.models.staking.NetworkType import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.staking.model.StakingAvailability import com.tangem.domain.staking.model.StakingEntryInfo import com.tangem.domain.staking.model.StakingIntegrationID -import com.tangem.domain.models.staking.NetworkType import com.tangem.domain.staking.model.stakekit.Yield import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionStatus @@ -55,7 +55,7 @@ interface StakeKitRepository { suspend fun estimateGas(userWalletId: UserWalletId, network: Network, params: ActionParams): StakingGasEstimate suspend fun constructTransaction( - networkId: String, + networkId: Network.RawID, fee: Fee, amount: Amount, transactionId: String, diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index 4c9a5154ca..e201062981 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -74,10 +74,10 @@ import com.tangem.features.send.v2.subcomponents.amount.SendAmountReduceTrigger import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.stripZeroPlainString +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal import javax.inject.Inject import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned @@ -281,8 +281,7 @@ internal class SendConfirmModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - blockchainId = cryptoCurrency.network.rawId, - derivationPath = cryptoCurrency.network.derivationPath.value, + networkId = cryptoCurrency.network.id, destinationAddress = confirmData.enteredDestination.orEmpty(), tokenSymbol = if (amount?.type is AmountType.Token) { amount.currencySymbol diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt index de06ea2a08..3a0d2b40c8 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/model/SendModel.kt @@ -18,6 +18,7 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -33,7 +34,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase @@ -65,9 +65,9 @@ import com.tangem.features.send.v2.subcomponents.destination.model.transformers. import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject import kotlin.properties.Delegates @@ -528,8 +528,7 @@ internal class SendModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage.orEmpty(), - blockchainId = cryptoCurrency.network.rawId, - derivationPath = cryptoCurrency.network.derivationPath.value, + networkId = cryptoCurrency.network.id, destinationAddress = "", tokenSymbol = "", amount = "", diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt index b15638b859..15754e5e6a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -20,13 +20,13 @@ import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.models.wallet.isHotWallet import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase @@ -57,10 +57,10 @@ import com.tangem.features.send.v2.sendnft.confirm.model.transformers.NFTSendCon import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.stripZeroPlainString +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal import javax.inject.Inject import com.tangem.features.send.v2.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned @@ -197,8 +197,7 @@ internal class NFTSendConfirmModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - blockchainId = cryptoCurrency.network.rawId, - derivationPath = cryptoCurrency.network.derivationPath.value, + networkId = cryptoCurrency.network.id, destinationAddress = confirmData.enteredDestination.orEmpty(), tokenSymbol = null, amount = params.nftAsset.amount?.toString().orEmpty(), @@ -379,7 +378,7 @@ internal class NFTSendConfirmModel @Inject constructor( flow = uiState, flow2 = params.currentRoute, transform = { state, route -> state to route }, - ).onEach { (state, route) -> + ).onEach { (state, _) -> val confirmUM = state.confirmUM params.callback.onResult( state.copy( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt index 54b8e11901..962b35e833 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/model/NFTSendModel.kt @@ -15,6 +15,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -27,7 +28,6 @@ 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.wallet.UserWallet -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.transaction.error.GetFeeError @@ -223,8 +223,7 @@ internal class NFTSendModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage.orEmpty(), - blockchainId = cryptoCurrency.network.rawId, - derivationPath = cryptoCurrency.network.derivationPath.value, + networkId = cryptoCurrency.network.id, destinationAddress = "", tokenSymbol = null, amount = "", diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index ccceb6379c..a2081de45c 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -2,6 +2,9 @@ package com.tangem.features.staking.impl.presentation.model import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee @@ -10,13 +13,8 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.arkivanov.decompose.router.slot.activate -import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig -import com.tangem.features.approval.api.GiveApprovalComponent -import com.tangem.features.approval.api.GiveApprovalFeatureToggles import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.ParamsInterceptorHolder @@ -36,11 +34,11 @@ import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.feedback.GetWalletMetaInfoUseCase import com.tangem.domain.feedback.SaveBlockchainErrorUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase @@ -61,12 +59,13 @@ import com.tangem.domain.staking.model.stakekit.action.StakingAction import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType import com.tangem.domain.staking.model.stakekit.transaction.StakingTransaction import com.tangem.domain.staking.repositories.P2PEthPoolRepository -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.tokens.* import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.usecase.* import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.approval.api.GiveApprovalComponent +import com.tangem.features.approval.api.GiveApprovalFeatureToggles import com.tangem.features.staking.api.StakingComponent import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.analytics.StakingParamsInterceptor @@ -100,13 +99,13 @@ import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_I import com.tangem.utils.coroutines.* import com.tangem.utils.extensions.isSingleItem import com.tangem.utils.extensions.orZero +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject @@ -1067,8 +1066,7 @@ internal class StakingModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - blockchainId = network.rawId, - derivationPath = network.derivationPath.value, + networkId = network.id, destinationAddress = target?.address.orEmpty(), tokenSymbol = (cryptoCurrencyStatus.currency as? CryptoCurrency.Token)?.symbol, amount = amount?.run { value?.toPlainString() + currencySymbol }.orEmpty(), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt index ca9f4e5992..8e16ba2154 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/helpers/StakeKitTransactionSender.kt @@ -33,13 +33,13 @@ import com.tangem.features.staking.impl.presentation.state.StakingUiState import com.tangem.features.staking.impl.presentation.state.utils.checkAndCalculateSubtractedAmount import com.tangem.features.staking.impl.presentation.state.utils.isCompositePendingActions import com.tangem.utils.extensions.orZero +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal @Suppress("LongParameterList") @@ -165,7 +165,7 @@ internal class StakeKitTransactionSender @AssistedInject constructor( ?.map { transaction -> async { getConstructedStakingTransactionUseCase( - networkId = cryptoCurrencyStatus.currency.network.rawId, + networkId = cryptoCurrencyStatus.currency.network.id.rawId, fee = fee, amount = amount.convertToSdkAmount(cryptoCurrencyStatus), transactionId = transaction.id, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt index 22ead70b9b..7e53fda3a2 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/SwapAlertFactory.kt @@ -75,8 +75,7 @@ internal class SwapAlertFactory @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage.orEmpty(), - blockchainId = cryptoCurrency?.network?.rawId.orEmpty(), - derivationPath = cryptoCurrency?.network?.derivationPath?.value.orEmpty(), + networkId = cryptoCurrency?.network?.id, destinationAddress = confirmData?.enteredDestination.orEmpty(), tokenSymbol = confirmData?.toCryptoCurrencyStatus?.currency?.symbol.orEmpty(), amount = confirmData?.enteredFromAmount?.toString().orEmpty(), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 5416aaad86..47be410214 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -2112,8 +2112,7 @@ internal class SwapModel @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - blockchainId = network.rawId, - derivationPath = network.derivationPath.value, + networkId = network.id, destinationAddress = transaction?.txTo.orEmpty(), tokenSymbol = fromCurrencyStatus.currency.symbol, amount = dataState.amount.orEmpty(), diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/YieldSupplyAlertFactory.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/YieldSupplyAlertFactory.kt index cec6ab682a..d6de0e0316 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/YieldSupplyAlertFactory.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/common/YieldSupplyAlertFactory.kt @@ -61,8 +61,7 @@ class YieldSupplyAlertFactory @Inject constructor( saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage.orEmpty(), - blockchainId = cryptoCurrency?.network?.rawId.orEmpty(), - derivationPath = cryptoCurrency?.network?.derivationPath?.value.orEmpty(), + networkId = cryptoCurrency?.network?.id, tokenSymbol = cryptoCurrency?.symbol.orEmpty(), destinationAddress = "", amount = "", From b38ebb399538b797b641c8daeffc360aebd81b89 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 13 Apr 2026 19:48:06 +0300 Subject: [PATCH 029/206] Updated on 2026-08-14 --- .../di/domain/DynamicAddressesDomainModule.kt | 8 +-- .../tangemTech/models/UserTokensResponse.kt | 4 +- .../DefaultDynamicAddressesRepository.kt | 16 ++++- .../DynamicAddressesInitializer.kt | 42 +++++++++++++ data/networks/build.gradle.kts | 1 + .../fetcher/CommonNetworkStatusFetcher.kt | 2 + .../multi/DefaultMultiNetworkStatusFetcher.kt | 15 ++++- .../DefaultMultiNetworkStatusFetcherTest.kt | 21 ++++++- .../DefaultWalletManagersFacade.kt | 17 +++++- .../DisableDynamicAddressesUseCase.kt | 2 +- .../DynamicAddressesSupportedBlockchains.kt | 23 +++---- .../dynamicaddresses/GetDerivedXpubUseCase.kt | 60 +++++++++++++++++++ .../dynamicaddresses/IsXpubDerivedUseCase.kt | 37 ------------ .../repository/DynamicAddressesRepository.kt | 2 +- .../walletmanager/WalletManagersFacade.kt | 2 + .../model/DynamicAddressesDelegate.kt | 15 +++-- 16 files changed, 196 insertions(+), 71 deletions(-) create mode 100644 data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt create mode 100644 domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt delete mode 100644 domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt index 3b9ec07528..0ab55063f9 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/DynamicAddressesDomainModule.kt @@ -5,7 +5,7 @@ import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase import com.tangem.domain.dynamicaddresses.GetDynamicAddressesStatusUseCase import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase -import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository @@ -69,10 +69,10 @@ internal object DynamicAddressesDomainModule { @Provides @Singleton - fun provideIsXpubDerivedUseCase( + fun provideGetDerivedXpubUseCase( walletManagersFacade: WalletManagersFacade, derivationsRepository: DerivationsRepository, - ): IsXpubDerivedUseCase { - return IsXpubDerivedUseCase(walletManagersFacade, derivationsRepository) + ): GetDerivedXpubUseCase { + return GetDerivedXpubUseCase(walletManagersFacade, derivationsRepository) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt index 4fe788204a..a4dccdfd90 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/UserTokensResponse.kt @@ -42,7 +42,8 @@ data class UserTokensResponse( return otherToken.contractAddress == this.contractAddress && otherToken.networkId == this.networkId && otherToken.derivationPath == this.derivationPath && - otherToken.decimals == this.decimals + otherToken.decimals == this.decimals && + otherToken.dynamicAddressesEnabled == this.dynamicAddressesEnabled } override fun hashCode(): Int = calculateHashCode( @@ -50,6 +51,7 @@ data class UserTokensResponse( networkId.hashCode(), derivationPath.hashCode(), decimals.hashCode(), + dynamicAddressesEnabled.hashCode(), ) } diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index c2c6203803..ce82539915 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -49,7 +49,12 @@ internal class DefaultDynamicAddressesRepository( } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } - .onFailure { TangemLogger.e("Failed to sync tokens after DA enable for $userWalletId", it) } + .onFailure { throwable -> + TangemLogger.e( + messageString = "Failed to sync tokens after dynamic addresses enable for $userWalletId", + throwable = throwable, + ) + } } } @@ -61,7 +66,12 @@ internal class DefaultDynamicAddressesRepository( } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } - .onFailure { TangemLogger.e("Failed to sync tokens after DA disable for $userWalletId", it) } + .onFailure { throwable -> + TangemLogger.e( + messageString = "Failed to sync tokens after dynamic addresses disable for $userWalletId", + throwable = throwable, + ) + } } } @@ -140,7 +150,7 @@ internal class DefaultDynamicAddressesRepository( } private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean { - // Check if WalletManager is already in XPUB mode (DA was previously enabled on this device) + // Check if WalletManager is already in XPUB mode (dynamic addresses was previously enabled on this device) return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null } diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt new file mode 100644 index 0000000000..6eae2bf3b5 --- /dev/null +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt @@ -0,0 +1,42 @@ +package com.tangem.data.dynamicaddresses + +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase +import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.logging.TangemLogger +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Provides XPUB strings for networks that need dynamic addresses restore (ENABLED_REQUIRES_SETUP). + * Uses only already-derived keys — no card scan triggered. + */ +@Singleton +class DynamicAddressesInitializer @Inject constructor( + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val getDerivedXpubUseCase: GetDerivedXpubUseCase, +) { + + suspend fun getXpubs(userWalletId: UserWalletId, networks: Set): Map { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return emptyMap() + + val result = mutableMapOf() + for (network in networks) { + val status = dynamicAddressesRepository.getStatus(userWalletId, network).firstOrNull() + if (status != DynamicAddressesStatus.ENABLED_REQUIRES_SETUP) continue + + val xpub = getDerivedXpubUseCase(userWalletId, network) + if (xpub != null) { + result[network] = xpub + } else { + TangemLogger.w("Dynamic addresses enabled but XPUB not available for ${network.id}") + } + } + return result + } +} \ No newline at end of file diff --git a/data/networks/build.gradle.kts b/data/networks/build.gradle.kts index d90e4e2a52..67c7f4eb40 100644 --- a/data/networks/build.gradle.kts +++ b/data/networks/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { // region Project - Data implementation(projects.data.common) + implementation(projects.data.dynamicAddresses) // endregion // region Project - Domain diff --git a/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt index 38625e0f4a..17293dccca 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/fetcher/CommonNetworkStatusFetcher.kt @@ -43,6 +43,7 @@ internal class CommonNetworkStatusFetcher @Inject constructor( userWalletId: UserWalletId, network: Network, networkCurrencies: Set, + xpub: String? = null, ): Either { return Either.catchOn(dispatchers.default) { val result = withContext(dispatchers.io) { @@ -52,6 +53,7 @@ internal class CommonNetworkStatusFetcher @Inject constructor( extraTokens = networkCurrencies .filterIsInstance() .toSet(), + xpub = xpub, ) } diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt index e429c134da..1f3e0860a0 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt @@ -2,16 +2,18 @@ package com.tangem.data.networks.multi import arrow.core.raise.catch import arrow.core.raise.ensure -import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher import com.tangem.data.networks.store.NetworksStatusesStore import com.tangem.data.networks.store.setSourceAsCache import com.tangem.data.networks.store.setSourceAsOnlyCache +import com.tangem.data.dynamicaddresses.DynamicAddressesInitializer +import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.core.utils.eitherOn import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -27,11 +29,11 @@ import javax.inject.Inject * [REDACTED_AUTHOR] */ -@Suppress("LongParameterList") internal class DefaultMultiNetworkStatusFetcher @Inject constructor( private val networksStatusesStore: NetworksStatusesStore, private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory, private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher, + private val dynamicAddressesInitializer: DynamicAddressesInitializer, private val dispatchers: CoroutineDispatcherProvider, ) : MultiNetworkStatusFetcher { @@ -50,6 +52,14 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor( }, ) + val xpubByNetwork = catch( + block = { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) }, + catch = { error -> + TangemLogger.e("Failed to build XPUBs for restore", error) + emptyMap() + }, + ) + val result = coroutineScope { params.networks .map { network -> @@ -58,6 +68,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor( userWalletId = params.userWalletId, network = network, networkCurrencies = networksCurrencies[network].orEmpty().toSet(), + xpub = xpubByNetwork[network], ) } } diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt index f02bcd3cd3..8a1a396ed2 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt @@ -3,6 +3,7 @@ package com.tangem.data.networks.multi import arrow.core.Either import arrow.core.left import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.data.dynamicaddresses.DynamicAddressesInitializer import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.data.networks.fetcher.CommonNetworkStatusFetcher import com.tangem.data.networks.store.NetworksStatusesStore @@ -27,17 +28,21 @@ internal class DefaultMultiNetworkStatusFetcherTest { private val networksStatusesStore: NetworksStatusesStore = mockk(relaxUnitFun = true) private val cardCryptoCurrencyFactory: CardCryptoCurrencyFactory = mockk() private val commonNetworkStatusFetcher: CommonNetworkStatusFetcher = mockk() + private val dynamicAddressesInitializer: DynamicAddressesInitializer = mockk() private val fetcher = DefaultMultiNetworkStatusFetcher( networksStatusesStore = networksStatusesStore, cardCryptoCurrencyFactory = cardCryptoCurrencyFactory, commonNetworkStatusFetcher = commonNetworkStatusFetcher, + dynamicAddressesInitializer = dynamicAddressesInitializer, dispatchers = TestingCoroutineDispatcherProvider(), ) @BeforeEach fun resetMocks() { - clearMocks(networksStatusesStore, cardCryptoCurrencyFactory, commonNetworkStatusFetcher) + clearMocks(networksStatusesStore, cardCryptoCurrencyFactory, commonNetworkStatusFetcher, dynamicAddressesInitializer) + // No dynamic addresses restore by default + coEvery { dynamicAddressesInitializer.getXpubs(any(), any()) } returns emptyMap() } @Test @@ -62,6 +67,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) } returns ethereumFetcherResult @@ -70,6 +76,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } returns cardanoFetcherResult @@ -87,11 +94,13 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) commonNetworkStatusFetcher.fetch( userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } @@ -122,6 +131,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) } returns ethereumFetcherResult @@ -130,6 +140,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } returns cardanoFetcherResult @@ -147,11 +158,13 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) commonNetworkStatusFetcher.fetch( userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } @@ -182,6 +195,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) } returns ethereumFetcherResult @@ -190,6 +204,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } returns cardanoFetcherResult @@ -207,11 +222,13 @@ internal class DefaultMultiNetworkStatusFetcherTest { userWalletId = params.userWalletId, network = ethereum.network, networkCurrencies = setOf(ethereum), + xpub = null, ) commonNetworkStatusFetcher.fetch( userWalletId = params.userWalletId, network = cardano.network, networkCurrencies = setOf(cardano), + xpub = null, ) } @@ -245,7 +262,7 @@ internal class DefaultMultiNetworkStatusFetcherTest { networksStatusesStore.setSourceAsOnlyCache(params.userWalletId, params.networks) } - coVerify(inverse = true) { commonNetworkStatusFetcher.fetch(any(), any(), any()) } + coVerify(inverse = true) { commonNetworkStatusFetcher.fetch(any(), any(), any(), any()) } } private companion object { diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index d86119e490..c23bb4c58e 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -59,7 +59,7 @@ import java.util.EnumSet import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject -@Suppress("LargeClass", "TooManyFunctions") +@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") internal class DefaultWalletManagersFacade @Inject constructor( private val walletManagersStore: WalletManagersStore, private val userWalletsListRepository: UserWalletsListRepository, @@ -85,6 +85,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( userWalletId: UserWalletId, network: Network, extraTokens: Set, + xpub: String?, ): UpdateWalletManagerResult { val userWallet = getUserWallet(userWalletId) val blockchain = network.toBlockchain() @@ -95,6 +96,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( blockchain = blockchain, derivationPath = derivationPath, extraTokens = extraTokens, + xpub = xpub, ) } @@ -309,6 +311,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( blockchain: Blockchain, derivationPath: String?, extraTokens: Set, + xpub: String? = null, ): UpdateWalletManagerResult { if (derivationPath != null && !userWallet.hasDerivation(blockchain, derivationPath)) { TangemLogger.w("Derivation missed for: $blockchain") @@ -326,6 +329,7 @@ internal class DefaultWalletManagersFacade @Inject constructor( } val isUpdated = updateWalletManagerTokensIfNeeded(walletManager, extraTokens) + if (xpub != null) restoreXpubModeIfNeeded(walletManager, xpub) return try { if (userWallet is UserWallet.Cold && demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) { @@ -499,6 +503,17 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + private fun restoreXpubModeIfNeeded(walletManager: WalletManager, xpub: String) { + val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return + + try { + dynamicAddressesManager.enableDynamicAddresses(xpub) + TangemLogger.i("Restored XPUB mode for ${walletManager.wallet.blockchain}") + } catch (e: Exception) { + TangemLogger.e("Failed to restore XPUB mode: ${e.message}") + } + } + // endregion Dynamic Addresses @Deprecated("Will be removed in future") diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt index 214c7ee0b7..d0bcbb4660 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt @@ -11,7 +11,7 @@ class DisableDynamicAddressesUseCase( /** * Returns true when consolidation is required before disabling (non-base balances exist), - * or false when DA was disabled immediately. + * or false when dynamic addresses was disabled immediately. */ suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either = Either.catch { diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt index 3e732ca6dc..c3df141dd3 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt @@ -1,12 +1,13 @@ package com.tangem.domain.dynamicaddresses import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId /** * List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode). * Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred). * - * Per ASMPT-005: DA is NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses. + * Dynamic addresses is NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses. * Only the default derivation style per blockchain is supported. */ object DynamicAddressesSupportedBlockchains { @@ -26,22 +27,22 @@ object DynamicAddressesSupportedBlockchains { Blockchain.RavencoinTestnet, ) - private val supportedNetworkIds = supported.map { it.id }.toSet() + private val supportedNetworkIds = supported.map { it.toNetworkId() }.toSet() /** * Allowed BIP purpose nodes per network ID. * BTC/LTC use BIP-84 (SegWit), others use BIP-44 (Legacy P2PKH). */ private val allowedPurposeByNetworkId: Map = buildMap { - put(Blockchain.Bitcoin.id, BIP84_PURPOSE) - put(Blockchain.BitcoinTestnet.id, BIP84_PURPOSE) - put(Blockchain.Litecoin.id, BIP84_PURPOSE) - put(Blockchain.BitcoinCash.id, BIP44_PURPOSE) - put(Blockchain.BitcoinCashTestnet.id, BIP44_PURPOSE) - put(Blockchain.Dogecoin.id, BIP44_PURPOSE) - put(Blockchain.Dash.id, BIP44_PURPOSE) - put(Blockchain.Ravencoin.id, BIP44_PURPOSE) - put(Blockchain.RavencoinTestnet.id, BIP44_PURPOSE) + put(Blockchain.Bitcoin.toNetworkId(), BIP84_PURPOSE) + put(Blockchain.BitcoinTestnet.toNetworkId(), BIP84_PURPOSE) + put(Blockchain.Litecoin.toNetworkId(), BIP84_PURPOSE) + put(Blockchain.BitcoinCash.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.BitcoinCashTestnet.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.Dogecoin.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.Dash.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.Ravencoin.toNetworkId(), BIP44_PURPOSE) + put(Blockchain.RavencoinTestnet.toNetworkId(), BIP44_PURPOSE) } fun isSupported(blockchain: Blockchain): Boolean = blockchain in supported diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt new file mode 100644 index 0000000000..4c3b3cd2bf --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt @@ -0,0 +1,60 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.common.extensions.calculateRipemd160 +import com.tangem.common.extensions.calculateSha256 +import com.tangem.crypto.NetworkType +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.wallets.derivations.DerivationsRepository + +/** + * Returns the XPUB string if account-level keys are already derived (no card scan needed), + * or null if keys are not available. + */ +class GetDerivedXpubUseCase( + private val walletManagersFacade: WalletManagersFacade, + private val derivationsRepository: DerivationsRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId, network: Network): String? { + val blockchain = network.toBlockchain() + if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return null + if (!blockchain.isBip44DerivationStyleXPUB()) return null + + val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return null + val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return null + if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return null + + val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey) + val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey) + + val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT)) + val parentPath = DerivationPath(accountPath.nodes.dropLast(1)) + + val childExtKey = existingKeys[accountPath] ?: return null + val parentExtKey = existingKeys[parentPath] ?: return null + + val parentFingerprint = parentExtKey.publicKey + .calculateSha256().calculateRipemd160() + .take(PARENT_FINGERPRINT_SIZE).toByteArray() + + val net = if (blockchain.isTestnet()) NetworkType.Testnet else NetworkType.Mainnet + return ExtendedPublicKey( + publicKey = childExtKey.publicKey, + chainCode = childExtKey.chainCode, + depth = accountPath.nodes.size, + parentFingerprint = parentFingerprint, + childNumber = accountPath.nodes.last().index, + ).serialize(net) + } + + private companion object { + const val ACCOUNT_PATH_DROP_COUNT = 2 + const val PARENT_FINGERPRINT_SIZE = 4 + } +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt deleted file mode 100644 index 3a62af2f51..0000000000 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/IsXpubDerivedUseCase.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.domain.dynamicaddresses - -import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.common.extensions.ByteArrayKey -import com.tangem.crypto.hdWallet.DerivationPath -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.derivations.DerivationsRepository - -/** - * Checks if the account-level XPUB key is already derived (no card scan needed). - */ -class IsXpubDerivedUseCase( - private val walletManagersFacade: WalletManagersFacade, - private val derivationsRepository: DerivationsRepository, -) { - - suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Boolean { - val blockchain = network.toBlockchain() - if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return false - if (!blockchain.isBip44DerivationStyleXPUB()) return false - - val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return false - val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return false - if (hdKey.path.nodes.size <= ACCOUNT_PATH_DROP_COUNT) return false - val accountPath = DerivationPath(hdKey.path.nodes.dropLast(ACCOUNT_PATH_DROP_COUNT)) - - val seedKey = ByteArrayKey(walletManager.wallet.publicKey.seedKey) - val existingKeys = derivationsRepository.getExistingDerivedKeys(userWalletId, seedKey) - return existingKeys[accountPath] != null - } - - private companion object { - const val ACCOUNT_PATH_DROP_COUNT = 2 - } -} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt index 4bcb627e16..eec289800e 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt @@ -20,6 +20,6 @@ interface DynamicAddressesRepository { suspend fun hasNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean - /** Returns true if there are custom tokens with change/index ≠ 0 that conflict with DA */ + /** Returns true if there are custom tokens with change/index ≠ 0 that conflict with dynamic addresses */ suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean } \ No newline at end of file diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index af4b9462bb..92824ca373 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -38,12 +38,14 @@ interface WalletManagersFacade { * @param userWalletId The ID of the user's wallet. * @param network The network. * @param extraTokens Additional tokens. + * @param xpub XPUB string to restore dynamic addresses mode if not yet active. * @return The result of updating the wallet manager. */ suspend fun update( userWalletId: UserWalletId, network: Network, extraTokens: Set, + xpub: String? = null, ): UpdateWalletManagerResult /** diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt index a6a548aa4e..63f7200f14 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/DynamicAddressesDelegate.kt @@ -12,7 +12,7 @@ import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase -import com.tangem.domain.dynamicaddresses.IsXpubDerivedUseCase +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -44,7 +44,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, private val sendTransactionUseCase: SendTransactionUseCase, - private val isXpubDerivedUseCase: IsXpubDerivedUseCase, + private val getDerivedXpubUseCase: GetDerivedXpubUseCase, private val dynamicAddressesRepository: DynamicAddressesRepository, private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, @@ -138,7 +138,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( ) } is EnableDynamicAddressesError.ServiceError -> { - TangemLogger.e("Failed to enable DA: ${error.cause.message}") + TangemLogger.e("Failed to enable dynamic addresses: ${error.cause.message}") _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( onDismissClick = dismissBottomSheet, ) @@ -201,7 +201,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( ) } .onFailure { e -> - TangemLogger.e("Failed to disable DA: ${e.message}") + TangemLogger.e("Failed to disable dynamic addresses: ${e.message}") _bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable( onDismissClick = dismissBottomSheet, ) @@ -277,9 +277,8 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( ) } - // TODO: Replace with actual DA documentation URL private fun onReadMoreClick() { - // Stub: open documentation about Dynamic Addresses consolidation + // TODO: Replace with actual URL } private fun onDisableClick() { @@ -319,7 +318,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( try { dynamicAddressesRepository.disable(userWalletId, network) } catch (e: Exception) { - TangemLogger.e("Failed to disable DA after consolidation: ${e.message}") + TangemLogger.e("Failed to disable dynamic addresses after consolidation: ${e.message}") } dismissBottomSheet() onDynamicAddressesStateChanged() @@ -338,7 +337,7 @@ internal class DynamicAddressesDelegate @AssistedInject constructor( // region Common private suspend fun isXpubAlreadyDerived(network: Network): Boolean { - return isXpubDerivedUseCase(userWalletId, network) + return getDerivedXpubUseCase(userWalletId, network) != null } private fun isUserCancellation(error: Throwable): Boolean { From ecb1c6d2722a818cfeeefc94dc50f2cf24415e61 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 12:00:17 +0300 Subject: [PATCH 030/206] Updated on 2026-08-14 --- .../send/warnings/CardanoWarningsTest.kt | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CardanoWarningsTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CardanoWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CardanoWarningsTest.kt new file mode 100644 index 0000000000..e7137a0293 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CardanoWarningsTest.kt @@ -0,0 +1,151 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.CARDANO_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendScreen +import com.tangem.screens.onSendAddressScreen +import com.tangem.screens.onSendScreen +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class CardanoWarningsTest : BaseTestCase() { + private val tokenName = "Cardano" + private val minAmount = "ADA 1.00" + + private val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title) + private val invalidAmountMessage = + getResourceString(R.string.send_notification_invalid_minimum_amount_text, minAmount, minAmount) + + @AllureId("4204") + @DisplayName("Warnings: check warning, when remains less than 1 ADA") + @Test + fun afterTransactionRemainsLessThanMinimumAmountTest() { + val sendAmount = "19" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(CARDANO_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount' warning is displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = true + ) + } + } + } + + @AllureId("4207") + @DisplayName("Warnings: check warning, when amount more than 1 ADA") + @Test + fun transactionAmountMoreThanOneTest() { + val sendAmount = "2.5" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(CARDANO_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount' warning is not displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } + + @AllureId("4210") + @DisplayName("Warnings: check warning, when remains more than 1 ADA") + @Test + fun afterTransactionRemainsMoreThanMinimumAmountTest() { + val sendAmount = "18" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + + step("Open 'Send Screen' with token: $tokenName") { + openSendScreen(tokenName) + } + step("Type '$sendAmount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(sendAmount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Type address in input text field") { + onSendAddressScreen { addressTextField.performTextReplacement(CARDANO_ADDRESS) } + } + step("Click on 'Next' button") { + onSendAddressScreen { nextButton.clickWithAssertion() } + } + step("Assert 'Invalid amount' warning is not displayed") { + checkSendWarning( + title = invalidAmountTitle, + message = invalidAmountMessage, + isDisplayed = false + ) + } + } + } +} \ No newline at end of file From 822ed8eb8089e5f944babac2319becdbf5f95b4e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 12:46:07 +0300 Subject: [PATCH 031/206] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 18 ++- .../tangem/common/constants/TestConstants.kt | 11 ++ .../common/utils/AddressComparisonHelper.kt | 88 +++++++++++++ .../com/tangem/common/utils/NetworkUtils.kt | 61 ++++++++- .../com/tangem/scenarios/AddressScenarios.kt | 122 ++++++++++++++++++ .../com/tangem/scenarios/BaseScenarios.kt | 51 ++++++++ .../screens/CreateMobileWalletPageObject.kt | 23 ++++ .../screens/CreateWalletStartPageObject.kt | 5 + .../com/tangem/screens/DialogPageObject.kt | 5 + .../tangem/screens/ImportWalletPageObject.kt | 54 ++++++++ .../tangem/screens/TesterMenuPageObject.kt | 39 ++++++ .../tangem/tests/hotWallet/AddressesTest.kt | 86 ++++++++++++ .../ui/test/ImportWalletScreenTestTags.kt | 6 + .../port/ui/AddExistingWalletImportContent.kt | 10 +- 14 files changed, 570 insertions(+), 9 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/common/utils/AddressComparisonHelper.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/CreateMobileWalletPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/ImportWalletPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/TesterMenuPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AddressesTest.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/ImportWalletScreenTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 3b89b53e54..6acf072e3c 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -8,6 +8,8 @@ import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import com.kaspersky.components.alluresupport.interceptors.step.ScreenshotStepInterceptor import com.kaspersky.components.alluresupport.withForcedAllureSupport import com.kaspersky.components.composesupport.config.addComposeSupport @@ -20,13 +22,14 @@ import com.tangem.common.rules.ApiEnvironmentRule import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.datasource.utils.WireMockRedirectInterceptor import com.tangem.domain.promo.PromoRepository import com.tangem.domain.promo.models.PromoId +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.tap.MainActivity import dagger.hilt.android.testing.HiltAndroidRule import io.qameta.allure.kotlin.Allure -import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.rules.RuleChain import org.junit.rules.TestRule @@ -58,6 +61,12 @@ abstract class BaseTestCase : TestCase( @Inject lateinit var promoRepository: PromoRepository + @Inject + lateinit var walletManagersStore: WalletManagersStore + + @Inject + lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase + private val hiltRule = HiltAndroidRule(this) private val apiEnvironmentRule = ApiEnvironmentRule() private val permissionRule = GrantPermissionRule.grant( @@ -72,7 +81,11 @@ abstract class BaseTestCase : TestCase( private val semanticTreePrinterRule = object : TestWatcher() { override fun failed(e: Throwable?, description: Description?) { - runCatching { printAllRoots() } + runCatching { + runBlocking { + withTimeoutOrNull(SEMANTIC_TREE_PRINT_TIMEOUT_MS) { printAllRoots() } + } + } } } @@ -174,5 +187,6 @@ abstract class BaseTestCase : TestCase( private companion object { const val WIREMOCK_BASE_URL_ARG = "wiremockBaseUrl" + const val SEMANTIC_TREE_PRINT_TIMEOUT_MS = 5_000L } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index fd5d74d374..8ad9db91da 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -35,6 +35,7 @@ object TestConstants { const val WAIT_UNTIL_TIMEOUT = 20_000L const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L + const val WAIT_UNTIL_TIMEOUT_VERY_LONG = 60_000L const val MARKETS_MAIN_NETWORK_SUFFIX = "MAIN" @@ -43,4 +44,14 @@ object TestConstants { const val USER_TOKENS_API_SCENARIO = "user_tokens_api" const val QUOTES_API_SCENARIO = "quotes_api" + + const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk" + const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " + + "hawk when" + const val SEED_PHRASE_18 = "crush idle include refuse expose kiss slot budget uphold when dinner certain holiday " + + "slow word armor butter suffer" + const val SEED_PHRASE_21 = "employ space oval venue wash clog zebra cover icon wash assist word debris inform " + + "cable meadow add game meat rigid pride" + const val SEED_PHRASE_24 = "force visit fresh brown razor target ill scissors figure cave feel genre cargo category " + + "bread much nature basic fun iron benefit egg error prosper" } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/AddressComparisonHelper.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/AddressComparisonHelper.kt new file mode 100644 index 0000000000..2faf0b96ab --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/AddressComparisonHelper.kt @@ -0,0 +1,88 @@ +package com.tangem.common.utils + +import com.tangem.utils.logging.TangemLogger +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertTrue + +/** + * Compares addresses from the app (clipboard JSON) with reference addresses from the QA tools API. + * + * Both JSON arrays contain objects with fields: blockchain, derivationPath, token (nullable), addresses (array). + * Comparison normalizes and sorts both arrays before diffing. + */ +object AddressComparisonHelper { + + fun compareAddresses(appJson: String, apiJson: String) { + val appEntries = parseAndNormalize(appJson) + val apiEntries = parseAndNormalize(apiJson) + + val missingInApp = apiEntries - appEntries.toSet() + val extraInApp = appEntries - apiEntries.toSet() + + if (missingInApp.isEmpty() && extraInApp.isEmpty()) { + TangemLogger.i("Address comparison passed: ${appEntries.size} entries match") + return + } + + val report = buildString { + appendLine("Address comparison FAILED") + if (missingInApp.isNotEmpty()) { + appendLine("\nMissing in app (expected from API but not found):") + missingInApp.forEach { appendLine(" - $it") } + } + if (extraInApp.isNotEmpty()) { + appendLine("\nExtra in app (found in app but not in API):") + extraInApp.forEach { appendLine(" - $it") } + } + appendLine("\nApp entries: ${appEntries.size}, API entries: ${apiEntries.size}") + } + + TangemLogger.e(report) + assertTrue(report, false) + } + + private fun parseAndNormalize(json: String): List { + val array = JSONArray(json) + val entries = mutableListOf() + + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + entries.add( + AddressEntry( + blockchain = normalizeBlockchainName(obj.getString("blockchain")), + derivationPath = obj.getString("derivationPath").trim(), + token = obj.optString("token", null)?.trim()?.lowercase(), + addresses = parseAddresses(obj).sorted(), + ), + ) + } + + return entries.sortedWith( + compareBy { it.blockchain } + .thenBy { it.derivationPath } + .thenBy { it.token }, + ) + } + + private val blockchainNameOverrides = mapOf( + "chia network" to "chia", + ) + + private fun normalizeBlockchainName(name: String): String { + val normalized = name.trim().lowercase() + return blockchainNameOverrides[normalized] ?: normalized + } + + private fun parseAddresses(obj: JSONObject): List { + val addressesArray = obj.getJSONArray("addresses") + return (0 until addressesArray.length()).map { addressesArray.getString(it) } + } + + private data class AddressEntry( + val blockchain: String, + val derivationPath: String, + val token: String?, + val addresses: List, + ) +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt index 48c2a5cc5a..27b8c945ad 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt @@ -16,10 +16,10 @@ fun getWcUri( TangemLogger.i("Getting WC URI for network: $network") val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) // Таймаут подключения - .readTimeout(60, TimeUnit.SECONDS) // Таймаут чтения ответа - .writeTimeout(30, TimeUnit.SECONDS) // Таймаут записи - .callTimeout(90, TimeUnit.SECONDS) // Общий таймаут запроса + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .callTimeout(90, TimeUnit.SECONDS) .build() val request = Request.Builder() @@ -58,6 +58,59 @@ fun getWcUri( } } +fun getAddressesFromApi( + seedKey: String, + baseUrl: String = "[REDACTED_ENV_URL]", +): String? { + TangemLogger.i("Getting addresses for seed key: $seedKey") + + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .callTimeout(90, TimeUnit.SECONDS) + .build() + + val request = Request.Builder() + .url("$baseUrl/addresses") + .get() + .build() + + return try { + client.newCall(request).execute().use { response -> + TangemLogger.i("Response code: ${response.code}") + + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + + val contentType = response.header("Content-Type") ?: "" + if (!contentType.contains("application/json") && !body.trimStart().startsWith("{")) { + TangemLogger.e("Unexpected response (not JSON), Content-Type: $contentType, body: $body") + return null + } + + val jsonObject = JSONObject(body) + val data = jsonObject.optJSONObject("data") ?: jsonObject + val seedData = data.optJSONArray(seedKey) + + if (seedData != null) { + TangemLogger.i("Got addresses for $seedKey: ${seedData.length()} entries") + seedData.toString() + } else { + TangemLogger.e("No data found for seed key: $seedKey") + null + } + } else { + val errorBody = response.body?.string() ?: "No error body" + TangemLogger.e("Request failed: ${response.code}, body: $errorBody") + null + } + } + } catch (e: Exception) { + TangemLogger.e("Error getting addresses", e) + null + } +} + fun checkServiceHealth( baseUrl: String = "[REDACTED_ENV_URL]" ): String? { diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt new file mode 100644 index 0000000000..f259dd60c4 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt @@ -0,0 +1,122 @@ +package com.tangem.scenarios + +import android.view.KeyEvent +import androidx.test.core.app.ApplicationProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG +import com.tangem.common.utils.AddressComparisonHelper +import com.tangem.common.utils.getClipboardText +import com.tangem.screens.onMainScreen +import com.tangem.screens.onTesterMenuScreen +import com.tangem.utils.StringsSigns.DASH_SIGN +import com.tangem.utils.logging.TangemLogger +import io.qameta.allure.kotlin.Allure.step +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertNotNull + +private const val WALLET_MANAGERS_SETTLE_MS = 5_000L +private const val WALLET_MANAGERS_POLL_INTERVAL_MS = 500L + +fun BaseTestCase.verifyAddresses(seedPhrase: String, apiAddressesJson: String) { + var appAddressesJson: String? = null + + step("Open 'Main Screen' with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Assert wallet balance = '$DASH_SIGN'") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + runCatching { onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } }.isSuccess + } + } + step("Assert 'Organize tokens' button is enabled") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + runCatching { onMainScreen { organizeTokensButton().assertIsEnabled() } }.isSuccess + } + } + step("Wait for all wallet managers to initialize") { + awaitWalletManagersStabilized() + } + step("Open tester menu") { + openTesterMenu() + } + step("Click on 'Addresses info' button") { + onTesterMenuScreen { addressesInfoButton.performClick() } + } + step("Click on 'JSON' tab") { + onTesterMenuScreen { jsonTab.performClick() } + } + step("Click on 'Copy' button") { + onTesterMenuScreen { copyButton.performClick() } + } + step("Get addresses JSON from clipboard") { + appAddressesJson = getClipboardText(ApplicationProvider.getApplicationContext()) + assertNotNull("Clipboard is empty after copying addresses", appAddressesJson) + } + step("Compare app addresses with API reference") { + AddressComparisonHelper.compareAddresses( + appJson = requireNotNull(appAddressesJson), + apiJson = apiAddressesJson, + ) + } +} + +private const val TESTER_MENU_MAX_ATTEMPTS = 3 + +/** + * Presses 'Volume Down' twice to open tester menu. + * Retries up to [TESTER_MENU_MAX_ATTEMPTS] times if the menu doesn't appear. + */ +private fun BaseTestCase.openTesterMenu() { + repeat(TESTER_MENU_MAX_ATTEMPTS) { attempt -> + waitForIdle() + device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN) + device.uiDevice.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN) + + val opened = runCatching { + onTesterMenuScreen { addressesInfoButton.assertIsDisplayed() } + }.isSuccess + + if (opened) { + TangemLogger.i("Tester menu opened on attempt ${attempt + 1}") + return + } + TangemLogger.w("Tester menu not opened on attempt ${attempt + 1}, retrying...") + } + error("Failed to open tester menu after $TESTER_MENU_MAX_ATTEMPTS attempts") +} + +/** + * Polls [walletManagersStore] until the wallet manager count stops growing for [WALLET_MANAGERS_SETTLE_MS]. + + * + * Uses [getAllSync] with a polling interval instead of Flow, because the Flow only emits on changes — + * if the count stabilizes, there would be no new emission to check the settle timeout against. + */ +private fun BaseTestCase.awaitWalletManagersStabilized() { + val walletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId + ?: error("No selected wallet found") + var lastSize = -1 + var stableStart = System.currentTimeMillis() + + runBlocking { + withTimeout(WAIT_UNTIL_TIMEOUT_VERY_LONG) { + while (true) { + val currentSize = walletManagersStore.getAllSync(walletId).size + val now = System.currentTimeMillis() + + if (currentSize != lastSize) { + TangemLogger.i("Wallet managers count: $currentSize (was $lastSize)") + lastSize = currentSize + stableStart = now + } else if (now - stableStart >= WALLET_MANAGERS_SETTLE_MS) { + TangemLogger.i("Wallet managers stabilized at $currentSize entries") + return@withTimeout + } + + delay(WALLET_MANAGERS_POLL_INTERVAL_MS) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 2d11046a5c..beb2b2d14f 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -59,6 +59,57 @@ fun BaseTestCase.openMainScreen( } } +fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String) { + step("Click on 'Accept' button") { + onDisclaimerScreen { acceptButton.clickWithAssertion() } + } + step("Click on 'Get started' button") { + onStoriesScreen { getStartedButton.clickWithAssertion() } + } + step("Click on 'Start with Mobile Wallet' button") { + onCreateWalletStartScreen { startWithMobileWalletButton.performClick() } + } + step("Click on 'Import existing wallet' button") { + onCreateMobileWalletScreen { importExistingWalletButton.performClick() } + } + step("Click on 'Phrase text field'") { + onImportWalletScreen { phraseTextField.performClick() } + } + step("Type seed phrase in 'Phrase text field'") { + onImportWalletScreen { phraseTextField.performTextReplacement(seedPhrase) } + } + step("Click on 'Import' button") { + onImportWalletScreen { + importButton.assertIsEnabled() + importButton.performClick() + } + } + step("Click on 'Continue' button") { + onImportWalletScreen { + continueButton.assertIsEnabled() + continueButton.performClick() + } + } + step("Click on 'Skip' button") { + onImportWalletScreen { skipButton.performClick() } + } + step("Click on 'Skip anyway' dialog button") { + onDialog { skipAnywayButton.performClick() } + } + step("Click on 'Finish' button") { + onImportWalletScreen { + finishButton.assertIsEnabled() + finishButton.performClick() + } + } + step("Assert 'Main' screen is displayed") { + onMainScreen { screenContainer.assertIsDisplayed() } + } + step("Dismiss Market Tooltip by clicking close button") { + onMarketsTooltipScreen { closeButton.clickWithAssertion() } + } +} + fun BaseTestCase.synchronizeAddresses( balance: String? = null, isBalanceAvailable: Boolean = true diff --git a/app/src/androidTest/kotlin/com/tangem/screens/CreateMobileWalletPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/CreateMobileWalletPageObject.kt new file mode 100644 index 0000000000..e83def9363 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/CreateMobileWalletPageObject.kt @@ -0,0 +1,23 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.core.ui.test.BaseButtonTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class CreateMobileWalletPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val importExistingWalletButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(R.string.hw_import_existing_wallet)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onCreateMobileWalletScreen(function: CreateMobileWalletPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/CreateWalletStartPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/CreateWalletStartPageObject.kt index b06335d752..6d875cd034 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/CreateWalletStartPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/CreateWalletStartPageObject.kt @@ -17,6 +17,11 @@ class CreateWalletStartPageObject(semanticsProvider: SemanticsNodeInteractionsPr hasText(getResourceString(OnboardingImplR.string.welcome_unlock_card)) useUnmergedTree = true } + + val startWithMobileWalletButton: KNode = child { + hasText(getResourceString(OnboardingImplR.string.welcome_create_wallet_mobile_title)) + useUnmergedTree = true + } } internal fun BaseTestCase.onCreateWalletStartScreen(function: CreateWalletStartPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index 13eef6b87d..ff268b8969 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -64,6 +64,11 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(R.string.common_change)) } + + val skipAnywayButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.access_code_alert_skip_ok)) + } } internal fun BaseTestCase.onDialog(function: DialogPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ImportWalletPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ImportWalletPageObject.kt new file mode 100644 index 0000000000..26d1af7a54 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/ImportWalletPageObject.kt @@ -0,0 +1,54 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.ImportWalletScreenTestTags +import com.tangem.core.ui.test.TopAppBarTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText + +class ImportWalletPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val phraseTextField: KNode = child { + hasTestTag(ImportWalletScreenTestTags.PHRASE_TEXT_FIELD) + useUnmergedTree = true + } + + val passphraseTextField: KNode = child { + hasTestTag(ImportWalletScreenTestTags.PASSPHRASE_TEXT_FIELD) + useUnmergedTree = true + } + + val importButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyChild(withText(getResourceString(R.string.common_import))) + useUnmergedTree = true + } + + val continueButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyChild(withText(getResourceString(R.string.common_continue))) + useUnmergedTree = true + } + + val skipButton: KNode = child { + hasTestTag(TopAppBarTestTags.MORE_BUTTON) + hasText(getResourceString(R.string.common_skip)) + useUnmergedTree = true + } + + val finishButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyChild(withText(getResourceString(R.string.common_finish))) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onImportWalletScreen(function: ImportWalletPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TesterMenuPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TesterMenuPageObject.kt new file mode 100644 index 0000000000..dce99464a3 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TesterMenuPageObject.kt @@ -0,0 +1,39 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.feature.tester.impl.R as TesterImplR +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.TopAppBarTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString + +class TesterMenuPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val backButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + useUnmergedTree = true + } + + val addressesInfoButton: KNode = child { + hasTestTag(BaseButtonTestTags.TEXT) + hasText(getResourceString(TesterImplR.string.addresses_info)) + useUnmergedTree = true + } + + val jsonTab: KNode = child { + hasText("JSON") + useUnmergedTree = true + } + + val copyButton: KNode = child { + hasContentDescription("Copy") + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTesterMenuScreen(function: TesterMenuPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AddressesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AddressesTest.kt new file mode 100644 index 0000000000..46695fa68b --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/hotWallet/AddressesTest.kt @@ -0,0 +1,86 @@ +package com.tangem.tests.hotWallet + +import com.tangem.common.BaseTestCase +import com.tangem.common.annotations.ApiEnv +import com.tangem.common.annotations.ApiEnvConfig +import com.tangem.common.constants.TestConstants.SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_15 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_18 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_21 +import com.tangem.common.constants.TestConstants.SEED_PHRASE_24 +import com.tangem.common.utils.checkServiceHealth +import com.tangem.common.utils.getAddressesFromApi +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.scenarios.verifyAddresses +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Assert.assertNotNull +import org.junit.Test + +@HiltAndroidTest +class AddressesTest : BaseTestCase() { + + private var apiAddressesJson: String? = null + + private fun setupAddressTestHooks(seedKey: String) = setupHooks( + additionalBeforeAppLaunchSection = { + val status = checkServiceHealth() + assertNotNull("QA tools service is unreachable ([REDACTED_ENV_URL]", status) + + apiAddressesJson = getAddressesFromApi(seedKey) + assertNotNull("Failed to fetch reference addresses for '$seedKey'", apiAddressesJson) + }, + ) + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("1792") + @DisplayName("Hot wallet: auto derivation addresses for seed 12") + @Test + fun seed12AddressesTest() { + setupAddressTestHooks("twelve").run { + verifyAddresses(SEED_PHRASE_12, requireNotNull(apiAddressesJson)) + } + } + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("5106") + @DisplayName("Hot wallet: auto derivation addresses for seed 15") + @Test + fun seed15AddressesTest() { + setupAddressTestHooks("fifteen").run { + verifyAddresses(SEED_PHRASE_15, requireNotNull(apiAddressesJson)) + } + } + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("5107") + @DisplayName("Hot wallet: auto derivation addresses for seed 18") + @Test + fun seed18AddressesTest() { + setupAddressTestHooks("eighteen").run { + verifyAddresses(SEED_PHRASE_18, requireNotNull(apiAddressesJson)) + } + } + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("5108") + @DisplayName("Hot wallet: auto derivation addresses for seed 21") + @Test + fun seed21AddressesTest() { + setupAddressTestHooks("twenty_one").run { + verifyAddresses(SEED_PHRASE_21, requireNotNull(apiAddressesJson)) + } + } + + @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) + @AllureId("5109") + @DisplayName("Hot wallet: auto derivation addresses for seed 24") + @Test + fun seed24AddressesTest() { + setupAddressTestHooks("twenty_four").run { + verifyAddresses(SEED_PHRASE_24, requireNotNull(apiAddressesJson)) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/ImportWalletScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/ImportWalletScreenTestTags.kt new file mode 100644 index 0000000000..3e641be3e5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/ImportWalletScreenTestTags.kt @@ -0,0 +1,6 @@ +package com.tangem.core.ui.test + +object ImportWalletScreenTestTags { + const val PHRASE_TEXT_FIELD = "IMPORT_WALLET_PHRASE_TEXT_FIELD" + const val PASSPHRASE_TEXT_FIELD = "IMPORT_WALLET_PASSPHRASE_TEXT_FIELD" +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt index d7bb9338b0..3b85b823dc 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/addexistingwallet/im/port/ui/AddExistingWalletImportContent.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue @@ -39,6 +40,7 @@ import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.TangemTextFieldsDefault import com.tangem.core.ui.components.keyboardAsState import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.test.ImportWalletScreenTestTags import com.tangem.features.hotwallet.addexistingwallet.im.port.ui.utils.InvalidWordsColorTransformation import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -73,9 +75,10 @@ internal fun AddExistingWalletImportContent(state: AddExistingWalletImportUM, mo ) OutlineTextFieldWithIcon( - modifier = modifier + modifier = Modifier .padding(horizontal = 16.dp) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(ImportWalletScreenTestTags.PASSPHRASE_TEXT_FIELD), value = state.passPhrase, onValueChange = state.passPhraseChange, iconResId = R.drawable.ic_information_24, @@ -130,7 +133,8 @@ private fun PhraseBlock(state: AddExistingWalletImportUM, modifier: Modifier = M OutlinedTextField( modifier = Modifier .fillMaxWidth() - .height(TangemTheme.dimens.size142), + .height(TangemTheme.dimens.size142) + .testTag(ImportWalletScreenTestTags.PHRASE_TEXT_FIELD), value = state.words, onValueChange = state.wordsChange, textStyle = TangemTheme.typography.body1, From 0098be2fe5fd6d521715ce77ad405642dc6a2738 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 10:22:25 +0000 Subject: [PATCH 032/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 891cb8a686..7dce45b0c8 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1489" +tangemBlockchainSdk = "develop-1487" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-603" +tangemCardSdk = "develop-602" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 30a9e37c940d46c84051dabfc38c24d9fc8940ef Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 11:43:39 +0100 Subject: [PATCH 033/206] Updated on 2026-08-14 --- features/tokendetails/impl/build.gradle.kts | 1 + .../DefaultTokenDetailsDeepLinkHandler.kt | 8 +- .../DefaultTokenDetailsDeepLinkHandlerTest.kt | 482 ++++++++++++++++++ 3 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index fb3369069e..c47f2c68ed 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -121,4 +121,5 @@ dependencies { testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index 3a3054beab..5013ca11f5 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -9,8 +9,8 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -18,7 +18,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.notifications.models.NotificationType -import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.tokens.wallet.WalletBalanceFetcher import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase @@ -26,12 +25,12 @@ import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnaly import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger @Suppress("LongParameterList") internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( @@ -118,9 +117,6 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) { val isMultiCurrency = userWallet.isMultiCurrency - // single-currency wallet with token (NODL) - userWallet is UserWallet.Cold && - userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken() when { isMultiCurrency -> cryptoCurrencyBalanceFetcher( userWalletId = userWallet.walletId, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..fd2e01e58b --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt @@ -0,0 +1,482 @@ +package com.tangem.feature.tokendetails.deeplink + +import arrow.core.Either +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.DERIVATION_PATH_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.common.wallets.error.SelectWalletError +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.tokens.wallet.WalletBalanceFetcher +import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.SelectWalletUseCase +import com.tangem.features.tangempay.TangemPayFeatureToggles +import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger +import com.tangem.utils.logging.TangemLogger +import io.mockk.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultTokenDetailsDeepLinkHandlerTest { + + private val appRouter: AppRouter = mockk() + private val selectWalletUseCase: SelectWalletUseCase = mockk() + private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk() + private val tokenDetailsDeepLinkActionTrigger: TokenDetailsDeepLinkActionTrigger = mockk() + private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk() + private val getUserWalletUseCase: GetUserWalletUseCase = mockk() + private val walletBalanceFetcher: WalletBalanceFetcher = mockk() + private val tangemPayFeatureToggles: TangemPayFeatureToggles = mockk() + private val singleAccountListSupplier: SingleAccountListSupplier = mockk() + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + mockkObject(TangemLogger) + every { analyticsEventHandler.send(any()) } just Runs + every { appRouter.push(any(), any()) } just Runs + } + + @Test + fun `GIVEN error instead of user wallet WHEN handle deeplink THEN get error`() = runTest { + val queryParams = mapOf(WALLET_ID_KEY to "011") + every { + getUserWalletUseCase.invoke( + userWalletId = UserWalletId( + "011" + ) + ) + } returns Either.Left( + value = GetUserWalletError.UserWalletNotFound + ) + every { TangemLogger.e("Error on getting user wallet") } just Runs + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { TangemLogger.e("Error on getting user wallet") } + } + + @Test + fun `GIVEN locked user wallet WHEN handle deeplink THEN get error`() = runTest { + val queryParams = mapOf(WALLET_ID_KEY to "011") + every { + getUserWalletUseCase.invoke( + userWalletId = UserWalletId( + "011" + ) + ) + } returns Either.Right( + value = mockk { every { isLocked } returns true } + ) + every { TangemLogger.e("Error on getting user wallet") } just Runs + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { TangemLogger.e("Error on getting user wallet") } + } + + @Test + fun `GIVEN error instead select wallet WHEN handle deeplink THEN get error`() = runTest { + val queryParams = mapOf(WALLET_ID_KEY to "011") + val userWalletId = UserWalletId("011") + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { every { isLocked } returns false } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Left( + value = SelectWalletError.UnableToSelectUserWallet + ) + every { TangemLogger.e("Error on selecting user wallet") } just Runs + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { TangemLogger.e("Error on selecting user wallet") } + } + + @Test + fun `GIVEN no crypto by wallet WHEN handle deeplink THEN get error`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + ) + val userWalletId = UserWalletId("011") + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null + val expectedErrorText = """ + Could not get crypto currency for + |- $NETWORK_ID_KEY: 123 + |- $TOKEN_ID_KEY: 321 + """.trimIndent() + every { TangemLogger.e(messageString = expectedErrorText) } just Runs + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { TangemLogger.e(messageString = expectedErrorText) } + } + + @Test + fun `GIVEN multicurrency wallet WHEN handle deeplink THEN push new route`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777" + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + val expectedRoute = AppRoute.CurrencyDetails( + userWalletId = userWalletId, + currency = expectedCryptoCurrency, + ) + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns true + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { + appRouter.push( + route = expectedRoute, + onComplete = any(), + ) + } + } + + @Test + fun `GIVEN single currency wallet WHEN handle deeplink THEN push new route`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777" + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs + + createHandler(scope = this, queryParams) + advanceUntilIdle() + verify { + walletDeepLinkActionTrigger.selectWallet(userWalletId) + } + } + + @ParameterizedTest + @ValueSource(strings = ["swap_status_update", "onramp_status_update"]) + fun `GIVEN type WHEN handle deeplink THEN token details deeplink triggered`(type: String) = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777", + TRANSACTION_ID_KEY to "000", + TYPE_KEY to type, + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs + coEvery { tokenDetailsDeepLinkActionTrigger.trigger("000") } just Runs + + createHandler(scope = this, queryParams) + advanceUntilIdle() + coVerify { + tokenDetailsDeepLinkActionTrigger.trigger("000") + } + } + + @ParameterizedTest + @ValueSource(strings = ["income_transaction", "promo", "unknown"]) + fun `GIVEN type WHEN handle deeplink THEN token details deeplink not triggered`(type: String) = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777", + TRANSACTION_ID_KEY to "000", + TYPE_KEY to type, + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs + coEvery { tokenDetailsDeepLinkActionTrigger.trigger("000") } just Runs + + createHandler(scope = this, queryParams) + advanceUntilIdle() + coVerify(exactly = 0) { + tokenDetailsDeepLinkActionTrigger.trigger("000") + } + } + + @Test + fun `GIVEN multicurrency wallet AND isFromOnNewIntent WHEN handle deeplink THEN fetch currency`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777" + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns true + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + every { + cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = expectedCryptoCurrency) + } just Runs + + createHandler(scope = this, queryParams, isFromOnNewIntent = true) + advanceUntilIdle() + verify { cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = expectedCryptoCurrency) } + } + + @Test + fun `GIVEN single currency wallet AND isFromOnNewIntent WHEN handle deeplink THEN fetch currency`() = runTest { + val queryParams = mapOf( + WALLET_ID_KEY to "011", + NETWORK_ID_KEY to "123", + TOKEN_ID_KEY to "321", + DERIVATION_PATH_KEY to "777" + ) + val userWalletId = UserWalletId("011") + val expectedCryptoCurrency = mockk { + every { network } returns mockk { + every { rawId } returns "123" + every { derivationPath } returns Network.DerivationPath.Card(value = "777") + } + every { id } returns CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( + rawId = "321", + derivationPath = "777" + ), + suffix = CryptoCurrency.ID.Suffix.RawID("321") + ) + } + every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { isMultiCurrency } returns false + every { walletId } returns userWalletId + every { isLocked } returns false + } + ) + coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right( + value = mockk { + every { walletId } returns userWalletId + } + ) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty( + userWalletId = userWalletId, + cryptoCurrencies = listOf(expectedCryptoCurrency), + ) + every { tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled } returns true + coEvery { + walletBalanceFetcher.invoke( + WalletBalanceFetcher.Params( + userWalletId = userWalletId, + isPaymentAccountRefactorEnabled = true + ) + ) + } returns mockk() + every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs + + createHandler(scope = this, queryParams, isFromOnNewIntent = true) + advanceUntilIdle() + coEvery { + walletBalanceFetcher.invoke( + WalletBalanceFetcher.Params( + userWalletId = userWalletId, + isPaymentAccountRefactorEnabled = true + ) + ) + } + } + + private fun createHandler( + scope: CoroutineScope, + queryParams: Map, + isFromOnNewIntent: Boolean = false, + ) { + DefaultTokenDetailsDeepLinkHandler( + scope = scope, + queryParams = queryParams, + isFromOnNewIntent = isFromOnNewIntent, + appRouter = appRouter, + selectWalletUseCase = selectWalletUseCase, + cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher, + tokenDetailsDeepLinkActionTrigger = tokenDetailsDeepLinkActionTrigger, + walletDeepLinkActionTrigger = walletDeepLinkActionTrigger, + analyticsEventHandler = analyticsEventHandler, + getUserWalletUseCase = getUserWalletUseCase, + walletBalanceFetcher = walletBalanceFetcher, + tangemPayFeatureToggles = tangemPayFeatureToggles, + singleAccountListSupplier = singleAccountListSupplier, + ) + } +} \ No newline at end of file From aa03d39dc0cbc7d981800dfed6bc3c99488c6d39 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 12:44:34 +0200 Subject: [PATCH 034/206] Updated on 2026-08-14 --- .../com/tangem/core/ui/components/inputrow/inner/PasteButton.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt index d816b39eb6..98b7616634 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/inner/PasteButton.kt @@ -45,7 +45,7 @@ fun PasteButton( ) { val clipboardManager = LocalClipboardManager.current val hapticFeedback = LocalHapticFeedback.current - val isPasteEnabled = !clipboardManager.getText()?.text.isNullOrEmpty() + val isPasteEnabled = clipboardManager.hasText() val color = if (isPasteEnabled) { backgroundColorEnabled } else { From 49bd0090ff7e21182c0597d446329a94d4c163b9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 14:50:40 +0400 Subject: [PATCH 035/206] Updated on 2026-08-14 --- data/settings/build.gradle.kts | 9 ++ .../DevHotWalletRestrictionManager.kt | 56 +++++++++++ .../ProdHotWalletRestrictionManager.kt | 21 ++++ .../data/settings/di/SettingsDataModule.kt | 17 ++++ .../DevHotWalletRestrictionManagerTest.kt | 99 +++++++++++++++++++ .../ProdHotWalletRestrictionManagerTest.kt | 31 ++++++ .../settings/HotWalletRestrictionManager.kt | 21 ++++ features/details/impl/build.gradle.kts | 2 +- .../preview/PreviewDetailsComponent.kt | 11 ++- .../details/model/UserWalletListModel.kt | 8 +- .../features/details/utils/ItemsBuilder.kt | 6 +- features/tester/impl/build.gradle.kts | 3 +- .../actions/TesterActionsContentState.kt | 8 +- .../actions/TesterActionsScreen.kt | 10 +- .../actions/TesterActionsViewModel.kt | 75 +++----------- .../impl/src/main/res/values/strings.xml | 2 +- features/welcome/impl/build.gradle.kts | 1 - .../welcome/impl/model/WelcomeModel.kt | 8 +- 18 files changed, 300 insertions(+), 88 deletions(-) create mode 100644 data/settings/src/main/java/com/tangem/data/settings/DevHotWalletRestrictionManager.kt create mode 100644 data/settings/src/main/java/com/tangem/data/settings/ProdHotWalletRestrictionManager.kt create mode 100644 data/settings/src/test/java/com/tangem/data/settings/DevHotWalletRestrictionManagerTest.kt create mode 100644 data/settings/src/test/java/com/tangem/data/settings/ProdHotWalletRestrictionManagerTest.kt create mode 100644 domain/settings/src/main/java/com/tangem/domain/settings/HotWalletRestrictionManager.kt diff --git a/data/settings/build.gradle.kts b/data/settings/build.gradle.kts index 3b71894edc..5bae11084f 100644 --- a/data/settings/build.gradle.kts +++ b/data/settings/build.gradle.kts @@ -13,6 +13,10 @@ android { namespace = "com.tangem.data.settings" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { implementation(projects.core.datasource) @@ -28,6 +32,11 @@ dependencies { kapt(deps.hilt.kapt) // endregion + // region Test + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) + // endregion + // region Others dependencies implementation(deps.jodatime) implementation(deps.kotlin.coroutines) diff --git a/data/settings/src/main/java/com/tangem/data/settings/DevHotWalletRestrictionManager.kt b/data/settings/src/main/java/com/tangem/data/settings/DevHotWalletRestrictionManager.kt new file mode 100644 index 0000000000..87b20c09a8 --- /dev/null +++ b/data/settings/src/main/java/com/tangem/data/settings/DevHotWalletRestrictionManager.kt @@ -0,0 +1,56 @@ +package com.tangem.data.settings + +import androidx.datastore.preferences.core.booleanPreferencesKey +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.get +import com.tangem.domain.settings.HotWalletRestrictionManager +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +/** + * Development implementation of [HotWalletRestrictionManager]. + * + * Reads and writes the restriction state from [AppPreferencesStore], + * allowing testers to toggle it via the Tester Menu. + * The preference [Flow] is converted to a [StateFlow] on construction, + * so [isCreationEnabledSync] can be called from non-suspending contexts. + * Defaults to `true` (restriction enabled) when no value is stored. + */ +internal class DevHotWalletRestrictionManager( + private val appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, +) : HotWalletRestrictionManager { + + private val isCreationEnabledState: StateFlow = + appPreferencesStore + .get(key = IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY, default = true) + .stateIn( + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + started = SharingStarted.Eagerly, + initialValue = true, + ) + + override fun isCreationEnabled(): StateFlow = isCreationEnabledState + + override fun isCreationEnabledSync(): Boolean = isCreationEnabledState.value + + override suspend fun toggleCreationEnabled() { + appPreferencesStore.editData { preferences -> + val isEnabled = preferences.getOrDefault( + key = IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY, + default = true, + ) + preferences[IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY] = !isEnabled + } + } + + private companion object { + val IS_HOT_WALLET_CREATION_RESTRICTION_ENABLED_KEY = + booleanPreferencesKey(name = "isHotWalletCreationRestrictionEnabled") + } +} \ No newline at end of file diff --git a/data/settings/src/main/java/com/tangem/data/settings/ProdHotWalletRestrictionManager.kt b/data/settings/src/main/java/com/tangem/data/settings/ProdHotWalletRestrictionManager.kt new file mode 100644 index 0000000000..0626befff2 --- /dev/null +++ b/data/settings/src/main/java/com/tangem/data/settings/ProdHotWalletRestrictionManager.kt @@ -0,0 +1,21 @@ +package com.tangem.data.settings + +import com.tangem.domain.settings.HotWalletRestrictionManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Production implementation of [HotWalletRestrictionManager]. + * + * Hot wallet creation restriction is always enabled in production builds — + * users must scan a physical Tangem card to add a wallet. + * [toggleCreationEnabled] is a no-op since the restriction cannot be changed at runtime. + */ +internal class ProdHotWalletRestrictionManager : HotWalletRestrictionManager { + + private val state: StateFlow = MutableStateFlow(true) + + override fun isCreationEnabled(): StateFlow = state + override fun isCreationEnabledSync(): Boolean = true + override suspend fun toggleCreationEnabled() = Unit +} \ No newline at end of file diff --git a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt index 8728678fe4..ebc5c40001 100644 --- a/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt +++ b/data/settings/src/main/java/com/tangem/data/settings/di/SettingsDataModule.kt @@ -1,12 +1,16 @@ package com.tangem.data.settings.di import android.content.Context +import com.tangem.data.settings.BuildConfig import com.tangem.data.settings.DefaultAppRatingRepository import com.tangem.data.settings.DefaultPermissionRepository import com.tangem.data.settings.DefaultSettingsRepository +import com.tangem.data.settings.DevHotWalletRestrictionManager +import com.tangem.data.settings.ProdHotWalletRestrictionManager import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.domain.settings.repositories.AppRatingRepository import com.tangem.domain.settings.repositories.PermissionRepository import com.tangem.domain.settings.repositories.SettingsRepository @@ -55,4 +59,17 @@ internal object SettingsDataModule { context = context, ) } + + @Provides + @Singleton + fun provideHotWalletRestrictionManager( + appPreferencesStore: AppPreferencesStore, + dispatchers: CoroutineDispatcherProvider, + ): HotWalletRestrictionManager { + return if (BuildConfig.TESTER_MENU_ENABLED) { + DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + } else { + ProdHotWalletRestrictionManager() + } + } } \ No newline at end of file diff --git a/data/settings/src/test/java/com/tangem/data/settings/DevHotWalletRestrictionManagerTest.kt b/data/settings/src/test/java/com/tangem/data/settings/DevHotWalletRestrictionManagerTest.kt new file mode 100644 index 0000000000..1cb2dacb6e --- /dev/null +++ b/data/settings/src/test/java/com/tangem/data/settings/DevHotWalletRestrictionManagerTest.kt @@ -0,0 +1,99 @@ +package com.tangem.data.settings + +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.get +import com.tangem.test.core.getEmittedValues +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DevHotWalletRestrictionManagerTest { + + private val appPreferencesStore = mockk(relaxed = true) + private val dispatchers: CoroutineDispatcherProvider = TestingCoroutineDispatcherProvider() + + @BeforeEach + fun setup() { + mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt") + } + + @AfterEach + fun tearDown() { + unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt") + } + + @Test + fun `GIVEN preference is true WHEN isCreationEnabled THEN emits true`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(true) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + + val emitted = getEmittedValues(manager.isCreationEnabled()) + + assertThat(emitted).containsExactly(true) + } + + @Test + fun `GIVEN preference is false WHEN isCreationEnabled THEN emits false`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(false) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + + val emitted = getEmittedValues(manager.isCreationEnabled()) + + assertThat(emitted).containsExactly(false) + } + + @Test + fun `GIVEN preference Flow emits true WHEN isCreationEnabledSync THEN returns cached true`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(true) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + + assertThat(manager.isCreationEnabledSync()).isTrue() + } + + @Test + fun `GIVEN preference Flow emits false WHEN isCreationEnabledSync THEN returns cached false`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(false) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + + assertThat(manager.isCreationEnabledSync()).isFalse() + } + + @Test + fun `WHEN toggleCreationEnabled THEN opens editData transaction`() = runTest { + every { + appPreferencesStore.get(key = any>(), default = true) + } returns flowOf(true) + coEvery { appPreferencesStore.editData(any()) } returns mockk(relaxed = true) + + val manager = DevHotWalletRestrictionManager(appPreferencesStore, dispatchers) + manager.toggleCreationEnabled() + + coVerify { appPreferencesStore.editData(any()) } + } +} \ No newline at end of file diff --git a/data/settings/src/test/java/com/tangem/data/settings/ProdHotWalletRestrictionManagerTest.kt b/data/settings/src/test/java/com/tangem/data/settings/ProdHotWalletRestrictionManagerTest.kt new file mode 100644 index 0000000000..32ca85becf --- /dev/null +++ b/data/settings/src/test/java/com/tangem/data/settings/ProdHotWalletRestrictionManagerTest.kt @@ -0,0 +1,31 @@ +package com.tangem.data.settings + +import com.google.common.truth.Truth.assertThat +import com.tangem.test.core.getEmittedValues +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ProdHotWalletRestrictionManagerTest { + + private val manager = ProdHotWalletRestrictionManager() + + @Test + fun `WHEN isCreationEnabled THEN always emits true`() = runTest { + val emitted = getEmittedValues(manager.isCreationEnabled()) + + assertThat(emitted).containsExactly(true) + } + + @Test + fun `WHEN isCreationEnabledSync THEN returns true`() = runTest { + assertThat(manager.isCreationEnabledSync()).isTrue() + } + + @Test + fun `WHEN toggleCreationEnabled THEN isCreationEnabledSync still returns true`() = runTest { + manager.toggleCreationEnabled() + assertThat(manager.isCreationEnabledSync()).isTrue() + } +} \ No newline at end of file diff --git a/domain/settings/src/main/java/com/tangem/domain/settings/HotWalletRestrictionManager.kt b/domain/settings/src/main/java/com/tangem/domain/settings/HotWalletRestrictionManager.kt new file mode 100644 index 0000000000..dfae240b0f --- /dev/null +++ b/domain/settings/src/main/java/com/tangem/domain/settings/HotWalletRestrictionManager.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.settings + +import kotlinx.coroutines.flow.StateFlow + +/** + * Manages the hot wallet creation restriction setting. + * + * When the restriction is enabled, users are forced to scan a physical Tangem card + * instead of being able to create a new software (hot) wallet. + */ +interface HotWalletRestrictionManager { + + /** Observes the current restriction state as a [StateFlow]. */ + fun isCreationEnabled(): StateFlow + + /** Returns the latest cached restriction state synchronously. */ + fun isCreationEnabledSync(): Boolean + + /** Toggles the restriction state. No-op in production. */ + suspend fun toggleCreationEnabled() +} \ No newline at end of file diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 742988403b..7fc4587458 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -19,7 +19,6 @@ dependencies { implementation(projects.features.disclaimer.api) implementation(projects.features.tester.api) implementation(projects.features.createWalletSelection.api) - implementation(projects.features.hotWallet.api) implementation(projects.features.tangempay.details.api) /* Project - Core */ @@ -49,6 +48,7 @@ dependencies { implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.legacy) + implementation(projects.domain.settings) implementation(projects.domain.visa) /* SDK */ diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt index f8cde8a2e6..15b152f01b 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt @@ -6,13 +6,15 @@ import com.tangem.core.decompose.navigation.DummyRouter import com.tangem.core.navigation.url.DummyUrlOpener import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsUM import com.tangem.features.details.ui.DetailsScreen import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder -import com.tangem.features.hotwallet.HotWalletFeatureToggles +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.runBlocking internal class PreviewDetailsComponent : DetailsComponent { @@ -20,9 +22,10 @@ internal class PreviewDetailsComponent : DetailsComponent { private val previewBlocks = runBlocking { ItemsBuilder( router = DummyRouter(), - hotWalletFeatureToggles = object : HotWalletFeatureToggles { - override val isWalletCreationRestrictionEnabled: Boolean = true - override val isAssetsDiscoveryEnabled: Boolean = true + hotWalletRestrictionManager = object : HotWalletRestrictionManager { + override fun isCreationEnabled(): StateFlow = MutableStateFlow(true) + override fun isCreationEnabledSync(): Boolean = true + override suspend fun toggleCreationEnabled() = Unit }, ).buildAll( isWalletConnectAvailable = true, diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index 5c00ab323d..dfa422bab2 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -19,8 +19,8 @@ import com.tangem.domain.wallets.usecase.UnlockWalletUseCase import com.tangem.features.details.entity.UserWalletListUM import com.tangem.features.details.entity.WalletReorderUM import com.tangem.features.details.impl.R +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.features.details.utils.UserWalletSaver -import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -40,7 +40,7 @@ internal class UserWalletListModel @Inject constructor( private val messageSender: UiMessageSender, override val dispatchers: CoroutineDispatcherProvider, private val userWalletSaver: UserWalletSaver, - private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val hotWalletRestrictionManager: HotWalletRestrictionManager, private val unlockWalletUseCase: UnlockWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val walletFeatureToggles: WalletFeatureToggles, @@ -48,6 +48,8 @@ internal class UserWalletListModel @Inject constructor( ) : Model() { private val isWalletSavingInProgress: MutableStateFlow = MutableStateFlow(value = false) + private val isWalletCreationRestrictionEnabled: StateFlow = + hotWalletRestrictionManager.isCreationEnabled() private val userWalletsFetcher = userWalletsFetcherFactory.create( messageSender = messageSender, onlyMultiCurrency = false, @@ -101,7 +103,7 @@ internal class UserWalletListModel @Inject constructor( private fun onAddNewWalletClick() { analyticsEventHandler.send(SignIn.ButtonAddWallet(AnalyticsParam.ScreensSources.Settings)) - if (hotWalletFeatureToggles.isWalletCreationRestrictionEnabled) { + if (isWalletCreationRestrictionEnabled.value) { withProgress(isWalletSavingInProgress) { userWalletSaver.scanAndSaveUserWallet(modelScope) } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index 9746070c35..7bd5b7bd35 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -6,9 +6,9 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.components.block.model.BlockUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.impl.R -import com.tangem.features.hotwallet.HotWalletFeatureToggles import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -20,7 +20,7 @@ private const val TANGEM_PAY_ITEM_ID = "get_tangem_pay" @ModelScoped internal class ItemsBuilder @Inject constructor( private val router: Router, - private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val hotWalletRestrictionManager: HotWalletRestrictionManager, ) { @Suppress("LongParameterList") @@ -36,7 +36,7 @@ internal class ItemsBuilder @Inject constructor( buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add) buildUserWalletListBlock().let(::add) - if (hotWalletFeatureToggles.isWalletCreationRestrictionEnabled && hasAnyMobileWallet) { + if (hotWalletRestrictionManager.isCreationEnabledSync() && hasAnyMobileWallet) { DetailsItemUM.UnderSectionText( id = "only_one_mobile_wallet_explanation", text = resourceReference(R.string.only_one_mobile_wallet_explanation), diff --git a/features/tester/impl/build.gradle.kts b/features/tester/impl/build.gradle.kts index 3c61c2ed70..f96c63f4b4 100644 --- a/features/tester/impl/build.gradle.kts +++ b/features/tester/impl/build.gradle.kts @@ -31,8 +31,6 @@ dependencies { /** Domain modules */ implementation(projects.domain.account) - implementation(projects.domain.appTheme) - implementation(projects.domain.appTheme.models) implementation(projects.domain.card) implementation(projects.domain.feedback) implementation(projects.domain.markets.models) @@ -42,6 +40,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.wallets) implementation(projects.domain.feedback.models) + implementation(projects.domain.settings) implementation(projects.data.common) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsContentState.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsContentState.kt index 9307de43a7..fe09060e52 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsContentState.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsContentState.kt @@ -1,23 +1,23 @@ package com.tangem.feature.tester.presentation.actions import androidx.compose.runtime.Immutable -import com.tangem.domain.apptheme.model.AppThemeMode import java.io.File internal data class TesterActionsContentState( val hideAllCurrenciesUM: HideAllCurrenciesUM, - val toggleAppThemeUM: ToggleAppThemeUM, + val toggleHotWalletRestrictionUM: ToggleHotWalletRestrictionUM, val shareLogsUM: ShareLogsUM, val onBackClick: () -> Unit, ) { + @Immutable sealed class HideAllCurrenciesUM { data class Clickable(val onClick: () -> Unit) : HideAllCurrenciesUM() data object Progress : HideAllCurrenciesUM() } - data class ToggleAppThemeUM( - val currentAppTheme: AppThemeMode, + data class ToggleHotWalletRestrictionUM( + val isEnabled: Boolean, val onClick: () -> Unit, ) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsScreen.kt index bad48fc458..a3e947b392 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsScreen.kt @@ -22,10 +22,9 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.utils.findActivity -import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.feature.tester.impl.R import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.HideAllCurrenciesUM -import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleAppThemeUM +import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleHotWalletRestrictionUM import com.tangem.utils.logging.TangemLogger import java.io.File @@ -57,10 +56,11 @@ internal fun TesterActionsScreen(state: TesterActionsContentState, modifier: Mod } item { - val config = state.toggleAppThemeUM + val config = state.toggleHotWalletRestrictionUM + val statusText = if (config.isEnabled) "ON" else "OFF" TesterActionItem( - name = stringResourceSafe(id = R.string.toggle_app_theme, config.currentAppTheme.name), + name = stringResourceSafe(id = R.string.toggle_hot_wallet_restriction, statusText), onClick = config.onClick, ) } @@ -125,7 +125,7 @@ private fun TesterActionsScreenSample(modifier: Modifier = Modifier) { TesterActionsScreen( state = TesterActionsContentState( hideAllCurrenciesUM = HideAllCurrenciesUM.Clickable {}, - toggleAppThemeUM = ToggleAppThemeUM(AppThemeMode.DEFAULT) {}, + toggleHotWalletRestrictionUM = ToggleHotWalletRestrictionUM(isEnabled = true) {}, shareLogsUM = TesterActionsContentState.ShareLogsUM(file = null), onBackClick = {}, ), diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsViewModel.kt index 5eeb879082..fb6e223f88 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/actions/TesterActionsViewModel.kt @@ -5,31 +5,25 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import arrow.core.getOrElse import com.tangem.data.common.account.WalletAccountsSaver -import com.tangem.domain.apptheme.ChangeAppThemeModeUseCase -import com.tangem.domain.apptheme.GetAppThemeModeUseCase -import com.tangem.domain.apptheme.model.AppThemeMode import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.HideAllCurrenciesUM -import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleAppThemeUM +import com.tangem.feature.tester.presentation.actions.TesterActionsContentState.ToggleHotWalletRestrictionUM import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @HiltViewModel internal class TesterActionsViewModel @Inject constructor( - private val changeAppThemeModeUseCase: ChangeAppThemeModeUseCase, - private val getAppThemeModeUseCase: GetAppThemeModeUseCase, private val feedbackRepository: FeedbackRepository, private val userWalletsListRepository: UserWalletsListRepository, private val walletAccountsSaver: WalletAccountsSaver, + private val hotWalletRestrictionManager: HotWalletRestrictionManager, ) : ViewModel() { var uiState: TesterActionsContentState by mutableStateOf(initialState) @@ -38,16 +32,16 @@ internal class TesterActionsViewModel @Inject constructor( private val initialState: TesterActionsContentState get() = TesterActionsContentState( hideAllCurrenciesUM = HideAllCurrenciesUM.Clickable(this::hideAllCurrencies), - toggleAppThemeUM = ToggleAppThemeUM( - currentAppTheme = AppThemeMode.DEFAULT, - onClick = this::toggleAppTheme, + toggleHotWalletRestrictionUM = ToggleHotWalletRestrictionUM( + isEnabled = hotWalletRestrictionManager.isCreationEnabledSync(), + onClick = this::toggleHotWalletRestriction, ), shareLogsUM = TesterActionsContentState.ShareLogsUM(file = feedbackRepository.getLogFile()), onBackClick = { /* no-op */ }, ) init { - bootstrapAppThemeModeUpdates() + bootstrapHotWalletRestrictionUpdates() } fun setupNavigation(router: InnerTesterRouter) { @@ -78,57 +72,16 @@ internal class TesterActionsViewModel @Inject constructor( ) } - private fun toggleAppTheme() = viewModelScope.launch { - val currentAppThemeMode = uiState.toggleAppThemeUM.currentAppTheme - val newAppThemeMode = when (currentAppThemeMode) { - AppThemeMode.FORCE_DARK -> AppThemeMode.FORCE_LIGHT - AppThemeMode.FORCE_LIGHT -> AppThemeMode.FOLLOW_SYSTEM - AppThemeMode.FOLLOW_SYSTEM -> AppThemeMode.FORCE_DARK - } - - TangemLogger.d( - """ - Change app theme mode - |- Current theme mode: $currentAppThemeMode - |- New theme mode: $newAppThemeMode - """.trimIndent(), - ) - - changeAppThemeModeUseCase(newAppThemeMode).onLeft { error -> - TangemLogger.e( - """ - Unable to change app theme mode - |- Error: $error - """.trimIndent(), - ) - } + private fun toggleHotWalletRestriction() = viewModelScope.launch { + hotWalletRestrictionManager.toggleCreationEnabled() } - private fun bootstrapAppThemeModeUpdates() { - getAppThemeModeUseCase() - .distinctUntilChanged() - .onEach { maybeAppThemeMode -> - TangemLogger.d( - """ - Current app theme mode updated - |- Previous app theme mode: ${uiState.toggleAppThemeUM.currentAppTheme} - |- New app theme mode: $maybeAppThemeMode - """.trimIndent(), - ) - + private fun bootstrapHotWalletRestrictionUpdates() { + hotWalletRestrictionManager.isCreationEnabled() + .onEach { isEnabled -> uiState = uiState.copy( - toggleAppThemeUM = uiState.toggleAppThemeUM.copy( - currentAppTheme = maybeAppThemeMode.getOrElse { error -> - TangemLogger.e( - """ - Unable to get current app theme mode, using default - |- Default theme mode: ${AppThemeMode.DEFAULT} - |- Error: $error - """.trimIndent(), - ) - - AppThemeMode.DEFAULT - }, + toggleHotWalletRestrictionUM = uiState.toggleHotWalletRestrictionUM.copy( + isEnabled = isEnabled, ), ) } diff --git a/features/tester/impl/src/main/res/values/strings.xml b/features/tester/impl/src/main/res/values/strings.xml index f6d9e5cf50..5edc7e890f 100644 --- a/features/tester/impl/src/main/res/values/strings.xml +++ b/features/tester/impl/src/main/res/values/strings.xml @@ -11,10 +11,10 @@ Environment toggles Tester actions Hide all currencies - Toggle app theme - %s Excluded blockchains Filter by name or symbol Blockchain providers + Hot wallet creation restriction - %s Share logs Test push Accounts diff --git a/features/welcome/impl/build.gradle.kts b/features/welcome/impl/build.gradle.kts index e21ec865c7..11cc45a04f 100644 --- a/features/welcome/impl/build.gradle.kts +++ b/features/welcome/impl/build.gradle.kts @@ -14,7 +14,6 @@ android { dependencies { implementation(projects.features.welcome.api) implementation(projects.features.wallet.api) - implementation(projects.features.hotWallet.api) /** Core */ implementation(projects.core.configToggles) diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt index 5c16293dd7..5ecd009e00 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt @@ -23,11 +23,11 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.settings.CanUseBiometryUseCase +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.NonBiometricUnlockWalletUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase -import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.features.welcome.impl.ui.state.WelcomeUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -54,7 +54,7 @@ internal class WelcomeModel @Inject constructor( private val trackingContextProxy: TrackingContextProxy, private val analyticsEventHandler: AnalyticsEventHandler, userWalletsFetcherFactory: UserWalletsFetcher.Factory, - private val hotWalletFeatureToggles: HotWalletFeatureToggles, + private val hotWalletRestrictionManager: HotWalletRestrictionManager, private val scanCardProcessor: ScanCardProcessor, private val messageSender: UiMessageSender, ) : Model() { @@ -90,6 +90,8 @@ internal class WelcomeModel @Inject constructor( private val walletsFetcherJobHolder = JobHolder() private val wallets = MutableStateFlow>(persistentListOf()) private var routedOut = false + private val isWalletCreationRestrictionEnabled: StateFlow = + hotWalletRestrictionManager.isCreationEnabled() init { modelScope.launch { @@ -181,7 +183,7 @@ internal class WelcomeModel @Inject constructor( private fun addWalletClick() { analyticsEventHandler.send(SignIn.ButtonAddWallet(AnalyticsParam.ScreensSources.SignIn)) - if (hotWalletFeatureToggles.isWalletCreationRestrictionEnabled) { + if (isWalletCreationRestrictionEnabled.value) { scanCard() } else { router.push(AppRoute.CreateWalletSelection) From 42d7ec82f9e92e0c5bb50538cdd508c8229d70f7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 1 Apr 2026 16:00:28 +0300 Subject: [PATCH 036/206] Updated on 2026-08-14 --- .../core/ui/ds/button/action/ActionButtons.kt | 129 ++++++++++++++++++ .../internal/TokenRowPriceChangeContent.kt | 2 +- .../main/res/drawable/ic_arrow_expand_24.xml | 18 +++ .../res/drawable/ic_chevron_small_left_24.xml | 12 ++ core/ui/src/main/res/drawable/ic_sort_24.xml | 17 ++- .../ui/components/common/WalletBalance.kt | 42 +----- 6 files changed, 175 insertions(+), 45 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt create mode 100644 core/ui/src/main/res/drawable/ic_arrow_expand_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_chevron_small_left_24.xml diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt new file mode 100644 index 0000000000..9a88602e17 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/action/ActionButtons.kt @@ -0,0 +1,129 @@ +package com.tangem.core.ui.ds.button.action + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.orEmpty +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +/** + * Action buttons row + * + * [Figma](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=9467-37845&m=dev) + * + * @param buttons list of buttons + * @param modifier modifier + */ +@Composable +fun ActionButtons(buttons: ImmutableList, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + buttons.forEachIndexed { index, button -> + key(button.text to index) { + val textColor = if (button.isEnabled) { + TangemTheme.colors2.text.neutral.primary + } else { + TangemTheme.colors2.text.status.disabled + } + Column( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2_5), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SecondaryTangemButton( + tangemIconUM = button.tangemIconUM, + onClick = button.onClick, + isEnabled = button.isEnabled, + shape = TangemButtonShape.Rounded, + ) + Text( + text = button.text.orEmpty().resolveReference(), + style = TangemTheme.typography2.calloutSemibold15, + color = textColor, + maxLines = 1, + ) + } + } + } + } +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ActionButtons_Preview( + @PreviewParameter(ActionButtonsPreviewProvider::class) params: ImmutableList, +) { + TangemThemePreviewRedesign { + ActionButtons( + buttons = params, + ) + } +} + +private class ActionButtonsPreviewProvider : PreviewParameterProvider> { + override val values: Sequence> + get() = sequenceOf( + persistentListOf( + TangemButtonUM( + text = stringReference("Send"), + tangemIconUM = previewIcon(R.drawable.ic_arrow_up_24, isEnabled = true), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + TangemButtonUM( + text = stringReference("Receive"), + tangemIconUM = previewIcon(R.drawable.ic_exchange_default_24, isEnabled = true), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + TangemButtonUM( + text = stringReference("Swap"), + tangemIconUM = previewIcon(R.drawable.ic_dollar_default_24, isEnabled = false), + onClick = { }, + isEnabled = false, + type = TangemButtonType.Secondary, + ), + ), + ) +} + +private fun previewIcon(iconRes: Int, isEnabled: Boolean): TangemIconUM = TangemIconUM.Icon( + iconRes = iconRes, + tintReference = { + if (isEnabled) { + TangemTheme.colors2.graphic.neutral.primary + } else { + TangemTheme.colors2.graphic.neutral.quaternary + } + }, +) +// endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt index 052c994ac7..14e61f4061 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/row/token/internal/TokenRowPriceChangeContent.kt @@ -19,7 +19,7 @@ import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.res.TangemTheme @Composable -internal fun RowScope.TokenRowPriceChangeContent( +fun RowScope.TokenRowPriceChangeContent( priceChangeState: PriceChangeState.Content, isFlickering: Boolean, isAvailable: Boolean = true, diff --git a/core/ui/src/main/res/drawable/ic_arrow_expand_24.xml b/core/ui/src/main/res/drawable/ic_arrow_expand_24.xml new file mode 100644 index 0000000000..ce56686a2c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_arrow_expand_24.xml @@ -0,0 +1,18 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_chevron_small_left_24.xml b/core/ui/src/main/res/drawable/ic_chevron_small_left_24.xml new file mode 100644 index 0000000000..b44b76019e --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_chevron_small_left_24.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_sort_24.xml b/core/ui/src/main/res/drawable/ic_sort_24.xml index 538a24d8e0..f2b3124b42 100644 --- a/core/ui/src/main/res/drawable/ic_sort_24.xml +++ b/core/ui/src/main/res/drawable/ic_sort_24.xml @@ -3,7 +3,18 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + + diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt index 41663f384a..7afd89c781 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -25,25 +24,21 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.ds.button.SecondaryTangemButton -import com.tangem.core.ui.ds.button.TangemButtonShape import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.button.action.ActionButtons import com.tangem.core.ui.ds.image.TangemDeviceIcon import com.tangem.core.ui.ds.placeholder.TextPlaceholder import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed -import com.tangem.core.ui.extensions.orEmpty import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveAnnotatedReference -import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.MainScreenTestTags import com.tangem.feature.wallet.presentation.preview.WalletBalancePreview import com.tangem.feature.wallet.presentation.preview.WalletPreviewData import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM -import com.tangem.feature.wallet.presentation.wallet.ui.components.fastForEach import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList @@ -152,41 +147,6 @@ private fun Balance(walletBalanceUM: WalletBalanceUM, isBalanceHidden: Boolean, } } -@Composable -private fun ActionButtons(buttons: ImmutableList) { - Row( - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - buttons.fastForEach { button -> - key(button.text) { - val textColor = if (button.isEnabled) { - TangemTheme.colors2.text.neutral.primary - } else { - TangemTheme.colors2.text.status.disabled - } - Column( - modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2_5), - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SecondaryTangemButton( - tangemIconUM = button.tangemIconUM, - onClick = button.onClick, - isEnabled = button.isEnabled, - shape = TangemButtonShape.Rounded, - ) - Text( - text = button.text.orEmpty().resolveReference(), - style = TangemTheme.typography2.bodySemibold15, - color = textColor, - ) - } - } - } - } -} - // region Preview @Composable @Preview(showBackground = true, widthDp = 360) From 5f619a56fef1ad7870546a27968e3e50c2690b29 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 14:38:11 +0300 Subject: [PATCH 037/206] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 5f8d49287b..1c95c2ee9a 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 5f8d49287bdfa12f6618fc6a580706b14e4a9c13 +Subproject commit 1c95c2ee9aaa0007ea203b1bd9477e874f8211be From 9a0fbcbe1b68e3e97e86ae97d97ce55e94453a50 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 15:45:21 +0400 Subject: [PATCH 038/206] Updated on 2026-08-14 --- ...ccountCryptoPortfolioItemStateConverter.kt | 38 +++---- .../addtoportfolio/AddToPortfolioComponent.kt | 9 -- .../addtoportfolio/AddToPortfolioManager.kt | 83 ++++++++++---- .../impl/addtoportfolio/AddTokenComponent.kt | 2 +- .../DefaultAddToPortfolioComponent.kt | 2 +- ...tAddToPortfolioPreselectedDataComponent.kt | 5 +- .../addtoportfolio/TokenActionsComponent.kt | 2 +- .../model/AddToPortfolioModel.kt | 63 ++++++----- .../addtoportfolio/model/AddTokenModel.kt | 11 +- .../addtoportfolio/model/TokenActionsModel.kt | 9 +- .../model/TokenActionsUiBuilder.kt | 9 +- .../ui/DefaultAddToPortfolioManager.kt | 102 ++++++++++++------ .../impl/DefaultMarketsPortfolioComponent.kt | 1 - .../impl/model/MarketsPortfolioModel.kt | 29 ++--- .../swap/DefaultSwapSelectTokensComponent.kt | 4 +- .../AvailableSwapPairsComponent.kt | 4 +- .../DefaultAvailableSwapPairsComponent.kt | 4 +- .../model/AvailableSwapPairsModel.kt | 83 +++++++------- .../impl/DefaultChooseTokenComponent.kt | 4 +- .../converter/ChooseTokenListItemConverter.kt | 11 +- .../impl/model/ChooseTokenModel.kt | 34 +++--- .../impl/model/MarketBlockDelegate.kt | 69 +++++------- 22 files changed, 324 insertions(+), 254 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt index 9780c3831c..1e759205fc 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountCryptoPortfolioItemStateConverter.kt @@ -127,24 +127,6 @@ class AccountCryptoPortfolioItemStateConverter( isFlickering = this.source.isFlickering(), ) - private fun createFiatAmountState(fiatBalance: TotalFiatBalance, appCurrency: AppCurrency): FiatAmountState { - return when (fiatBalance) { - TotalFiatBalance.Failed, - TotalFiatBalance.Loading, - -> FiatAmountState.Empty - - is TotalFiatBalance.Loaded -> FiatAmountState.Content( - text = fiatBalance.amount.format { - fiat( - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ) - }, - isFlickering = fiatBalance.source == StatusSource.CACHE, - ) - } - } - private fun createSubtitle2State(priceChangeLce: Lce?): Subtitle2State? { return priceChangeLce?.fold( ifLoading = { priceChange -> priceChange?.toSubtitle2State() ?: Subtitle2State.Loading }, @@ -152,4 +134,24 @@ class AccountCryptoPortfolioItemStateConverter( ifContent = { priceChange -> priceChange.toSubtitle2State() }, ) } + + companion object { + fun createFiatAmountState(fiatBalance: TotalFiatBalance, appCurrency: AppCurrency): FiatAmountState { + return when (fiatBalance) { + TotalFiatBalance.Failed, + TotalFiatBalance.Loading, + -> FiatAmountState.Empty + + is TotalFiatBalance.Loaded -> FiatAmountState.Content( + text = fiatBalance.amount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + isFlickering = fiatBalance.source == StatusSource.CACHE, + ) + } + } + } } \ No newline at end of file diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt index 7e3d52793e..2261f0335d 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt @@ -2,21 +2,12 @@ package com.tangem.features.commonfeatures.api.addtoportfolio import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.domain.models.currency.CryptoCurrency interface AddToPortfolioComponent : ComposableBottomSheetComponent { data class Params( val addToPortfolioManager: AddToPortfolioManager, - val callback: Callback, - val shouldSkipTokenActionsScreen: Boolean = false, ) - interface Callback { - fun onDismiss() - // todo swap add new onSuccess with full data of added token - fun onSuccess(addedToken: CryptoCurrency) - } - interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt index 594e0e18ec..9ab9c5a86c 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt @@ -2,43 +2,90 @@ package com.tangem.features.commonfeatures.api.addtoportfolio import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.serialization.Serializable -interface AddToPortfolioManager { +interface AddToPortfolioManager : AddToPortfolioManagerInternal { + + val onDismiss: Channel + val onSuccessAdded: Channel - // todo swap make updatable - val token: TokenMarketParams - val analyticsParams: AnalyticsParams? val portfolioFetcher: PortfolioFetcher - val state: StateFlow - val allAvailableNetworks: Flow> fun setTokenNetworks(networks: List) + fun setTokenParams(token: TokenMarketParams) sealed interface State { - data object Init : State - data class AvailableToAdd( + data object Loading : State + data class Ready( val availableToAddData: AvailableToAddData, - ) : State - - data object NothingToAdd : State + ) : State { + val isAvailableToAdd: Boolean get() = availableToAddData.isAvailableToAdd + val isSinglePortfolio: Boolean get() = availableToAddData.isSinglePortfolio + } } @Serializable data class AnalyticsParams( - val source: String, + val source: String?, ) interface Factory { - fun create( - scope: CoroutineScope, - token: TokenMarketParams, - analyticsParams: AnalyticsParams?, - ): AddToPortfolioManager + fun create(scope: CoroutineScope, settings: Settings, analyticsParams: AnalyticsParams): AddToPortfolioManager } + + /** + * Immutable settings + */ + data class Settings( + val shouldSkipTokenActionsScreen: Boolean = false, + ) { + companion object { + val DefaultMarket = Settings( + shouldSkipTokenActionsScreen = false, + ) + val ChooseToken = Settings( + shouldSkipTokenActionsScreen = true, + ) + } + } + + /** + * Mutable parameters + * Updates may trigger reload [State] + */ + data class Params( + val networks: List, + val token: TokenMarketParams, + ) + + data class Result( + val wallet: UserWallet, + val account: AccountStatus.CryptoPortfolio, + val addedCurrency: CryptoCurrencyStatus, + ) +} + +/** + * primary for internal impl usage, but you can also use it externally + */ +interface AddToPortfolioManagerInternal { + val paramsFlow: SharedFlow + val settings: AddToPortfolioManager.Settings + val analyticsParams: AnalyticsParams + + suspend fun token(): TokenMarketParams = paramsFlow.first().token + + fun onDismiss() + fun onSuccessAdded(result: AddToPortfolioManager.Result) } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt index 9175781e37..a893a9c1c4 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt @@ -36,7 +36,7 @@ internal class AddTokenComponent @AssistedInject constructor( } data class Params( - val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val eventBuilder: Flow, val selectedPortfolio: Flow, val selectedNetwork: Flow, val callbacks: Callbacks, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt index b072275b3b..6ce1279b9d 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt @@ -71,7 +71,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( } override fun dismiss() { - params.callback.onDismiss() + model.addToPortfolioManager.onDismiss() } @Composable diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt index f2b8949242..f298a48b32 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt @@ -10,12 +10,13 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioPreselectedDataModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.flowOf internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @@ -37,7 +38,7 @@ internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject con private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( context = child("addTokenComponent"), params = AddTokenComponent.Params( - eventBuilder = model.eventBuilder, + eventBuilder = flowOf(model.eventBuilder), callbacks = model, selectedPortfolio = model.selectedPortfolio, selectedNetwork = model.selectedNetwork, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt index 4f2af5aa01..c5e6430356 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt @@ -63,7 +63,7 @@ internal class TokenActionsComponent @AssistedInject constructor( ) data class Params( - val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + val eventBuilder: Flow, val data: Flow, val callbacks: Callbacks, ) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 4582cc2853..a0b119a4ab 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -19,6 +19,7 @@ import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.account.PortfolioFetcher import com.tangem.features.account.PortfolioSelectorController import com.tangem.features.commonfeatures.api.addtoportfolio.* import com.tangem.features.commonfeatures.impl.R @@ -63,12 +64,9 @@ internal class AddToPortfolioModel @Inject constructor( val selectedPortfolio: MutableSharedFlow = replayMutableSharedFlow() val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() - private val addToPortfolioManager = params.addToPortfolioManager - val portfolioFetcher = addToPortfolioManager.portfolioFetcher - val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( - tokenSymbol = addToPortfolioManager.token.symbol, - source = addToPortfolioManager.analyticsParams?.source, - ) + val addToPortfolioManager: AddToPortfolioManager = params.addToPortfolioManager + val portfolioFetcher: PortfolioFetcher = addToPortfolioManager.portfolioFetcher + val eventBuilder: MutableSharedFlow = replayMutableSharedFlow() val featureData: Flow = combineFeatureData() @@ -85,32 +83,27 @@ internal class AddToPortfolioModel @Inject constructor( @Suppress("LongMethod") private fun startAddToPortfolioFlow() { channelFlow { - fun finishFlow() { - params.callback.onDismiss() - channel.close() - } - - fun finishSuccessFlow(addedToken: CryptoCurrencyStatus) { - params.callback.onSuccess(addedToken.currency) + fun finishSuccessFlow(result: AddToPortfolioManager.Result) { + addToPortfolioManager.onSuccessAdded(result) channel.close() } val featureDataFlow: StateFlow = featureData - .filterIsInstance() + .filterIsInstance() .map { it.availableToAddData } .distinctUntilChanged() .stateIn(this) val isAccountMode = portfolioSelectorController.isAccountModeSync() + val tokenMarketParams = addToPortfolioManager.paramsFlow.first().token + val eb = PortfolioAnalyticsEvent.EventBuilder( + tokenSymbol = tokenMarketParams.symbol, + source = addToPortfolioManager.analyticsParams.source, + ) + eventBuilder.tryEmit(eb) // use snapshot data, looks like we don’t need to remap at runtime val data = featureDataFlow.value - // you must control it via [AddToPortfolioManager.state] - if (!data.isAvailableToAdd) { - finishFlow() - return@channelFlow - } - // init data flows, emits on user/code selection, updates state holder val firstSelectedPortfolio = setupPortfolioFlow(data) .onEach { selectedPortfolio.emit(it) } @@ -157,7 +150,7 @@ internal class AddToPortfolioModel @Inject constructor( // line of navigation to AddToken screen is finished; cancel the job, select a new root screen firstPartOfNavigation.cancel() - analyticsEventHandler.send(event = eventBuilder.popupToConfirm()) + analyticsEventHandler.send(event = eventBuilder.first().popupToConfirm()) navigation.replaceAll(AddToPortfolioRoutes.AddToken) var middleNavigationJob: Job? = null @@ -185,34 +178,39 @@ internal class AddToPortfolioModel @Inject constructor( val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() middleNavigationJob?.cancel() val selectedPortfolio = selectedPortfolio.first() + val result = AddToPortfolioManager.Result( + wallet = selectedPortfolio.userWallet, + account = selectedPortfolio.account.account, + addedCurrency = addedToken, + ) messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) - if (params.shouldSkipTokenActionsScreen) { - finishSuccessFlow(addedToken) + if (addToPortfolioManager.settings.shouldSkipTokenActionsScreen) { + finishSuccessFlow(result) } else { setupTokenActionsFlow(selectedPortfolio, addedToken) .onEach { cryptoCurrencyData -> tokenActionsData.emit(cryptoCurrencyData) navigation.replaceAll(AddToPortfolioRoutes.TokenActions) } - .onEmpty { finishFlow() } + .onEmpty { finishSuccessFlow(result) } .launchIn(this) } callbackDelegate.onLaterClick.receiveAsFlow().first() - finishFlow() + finishSuccessFlow(result) } .catch { throwable -> TangemLogger.e("Error", throwable) - params.callback.onDismiss() + addToPortfolioManager.onDismiss() } .launchIn(modelScope) } - private fun logAccountSelector(isAccountMode: Boolean) { + private suspend fun logAccountSelector(isAccountMode: Boolean) { if (isAccountMode) { - analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) + analyticsEventHandler.send(eventBuilder.first().popupToChooseAccount()) } } @@ -291,7 +289,7 @@ internal class AddToPortfolioModel @Inject constructor( data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null val availableToAddAccount = availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null - if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) + if (!isAccountMode) analyticsEventHandler.send(eventBuilder.first().addToPortfolioWalletChanged()) SelectedPortfolio( isAccountMode = isAccountMode, userWallet = availableToAddWallets.userWallet, @@ -327,7 +325,7 @@ internal class AddToPortfolioModel @Inject constructor( val accountIndex = account.account.account.derivationIndex return getTokenMarketCryptoCurrency( userWalletId = userWallet.walletId, - tokenMarketParams = addToPortfolioManager.token, + tokenMarketParams = addToPortfolioManager.token(), network = network, accountIndex = accountIndex, ) @@ -339,7 +337,7 @@ internal class AddToPortfolioModel @Inject constructor( private fun combineFeatureData() = addToPortfolioManager.state.onEach { state -> when (state) { - is AddToPortfolioManager.State.AvailableToAdd -> + is AddToPortfolioManager.State.Ready -> portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId] ?: return@isEnabled false @@ -348,8 +346,7 @@ internal class AddToPortfolioModel @Inject constructor( ?.isAvailableToAdd == true return@isEnabled isAvailableAccount } - AddToPortfolioManager.State.Init, - AddToPortfolioManager.State.NothingToAdd, + AddToPortfolioManager.State.Loading, -> Unit } } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt index 4bb4f90351..ad1eee5632 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt @@ -12,11 +12,11 @@ import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.wallets.usecase.ColdWalletAndHasMissedDerivationsUseCase -import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent -import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenUiBuilder.Companion.toggleProgress import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenUiBuilder.Companion.toggleProgress import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -40,7 +40,6 @@ internal class AddTokenModel @Inject constructor( ) : Model() { private val params = paramsContainer.require() - private val analyticsEventBuilder = params.eventBuilder private val addTokenJob = JobHolder() val uiState: StateFlow @@ -86,10 +85,11 @@ internal class AddTokenModel @Inject constructor( uiState.value = um.toggleProgress(true) val blockchainNames = listOf(selectedNetwork.selectedNetwork) .mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name } + val analyticsEventBuilder = params.eventBuilder.first() analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames)) analyticsEventHandler.send(analyticsEventBuilder.addButtonClick()) - manageCryptoCurrenciesUseCase(accountId = accountId, add = cryptoCurrency) + manageCryptoCurrenciesUseCase.invokeAndAwait(accountId = accountId, add = listOf(cryptoCurrency)) .onLeft { throwable -> processError(error = throwable) uiState.value = um.toggleProgress(false) @@ -100,7 +100,8 @@ internal class AddTokenModel @Inject constructor( userWalletId = accountId.userWalletId, currencyId = cryptoCurrency.id, network = cryptoCurrency.network, - ).firstOrNull() + ) + .firstOrNull() if (status == null) { processError(error = null) } else { diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt index 2a9e691f46..575c386ebd 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt @@ -18,6 +18,7 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -52,18 +53,18 @@ internal class TokenActionsModel @Inject constructor( val bottomSheetNavigation: SlotNavigation = SlotNavigation() val uiState: StateFlow = params.data - .mapLatest { uiBuilder.build(it, tokenActionsHandler) } + .mapLatest { uiBuilder.build(it, tokenActionsHandler, analyticsEventBuilder.first()) } .stateIn( scope = modelScope, started = SharingStarted.Eagerly, initialValue = null, ) - private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) { - val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action) + private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch { + val event = analyticsEventBuilder.first().getTokenActionClick(actionUM = handledAction.action) analyticsEventHandler.send(event) val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive - if (!isReceive) return + if (!isReceive) return@launch modelScope.launch { val tokenConfig = receiveAddressesFactory.create( status = handledAction.cryptoCurrencyData.status, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt index 5b12b3efd4..2e6e031449 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIco import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.stringReference import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM import javax.inject.Inject @@ -20,7 +21,11 @@ internal class TokenActionsUiBuilder @Inject constructor( ) { private val params = paramsContainer.require() - fun build(data: CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): TokenActionsUM { + fun build( + data: CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + ): TokenActionsUM { val status = data.status val tokenUM = TokenItemState.Content( id = status.currency.id.value, @@ -35,7 +40,7 @@ internal class TokenActionsUiBuilder @Inject constructor( return TokenActionsUM( token = tokenUM, onLaterClick = { - analyticsEventHandler.send(params.eventBuilder.getTokenLater()) + analyticsEventHandler.send(eventBuilder.getTokenLater()) params.callbacks.onLaterClick() }, quickActions = quickActions(data, tokenActionsHandler), diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt index 0093289a88..a0aa017d17 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt @@ -3,69 +3,109 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.ui import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.AvailableToAddDataConverter import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.Settings +import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.AvailableToAddDataConverter import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* internal class DefaultAddToPortfolioManager @AssistedInject constructor( private val availableToAddDataConverter: AvailableToAddDataConverter, - @Assisted override val token: TokenMarketParams, - @Assisted override val analyticsParams: AddToPortfolioManager.AnalyticsParams?, + @Assisted override val settings: Settings, + @Assisted override val analyticsParams: AnalyticsParams, @Assisted val scope: CoroutineScope, dispatchers: CoroutineDispatcherProvider, portfolioFetcherFactory: PortfolioFetcher.Factory, ) : AddToPortfolioManager { - private val _allAvailableNetworks = MutableSharedFlow>( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) + override val onDismiss: Channel = Channel() + override val onSuccessAdded: Channel = Channel() - override val allAvailableNetworks: Flow> = _allAvailableNetworks.asSharedFlow() override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), scope = scope, ) - override val state: StateFlow = - combine( - flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(), - flow2 = allAvailableNetworks.map { it.toSet() }.distinctUntilChanged(), - ) { balances, availableNetworks -> - val data = availableToAddDataConverter.convert( - balances = balances, - availableNetworks = availableNetworks, - marketParams = token, + private val internalParamsFlow = MutableStateFlow(ParamsInternal()) + + override val paramsFlow = internalParamsFlow + .transform { internalParams -> + val fullParams = AddToPortfolioManager.Params( + networks = internalParams.networks ?: return@transform, + token = internalParams.token ?: return@transform, ) - if (data.isAvailableToAdd) { - AddToPortfolioManager.State.AvailableToAdd(data) - } else { - AddToPortfolioManager.State.NothingToAdd - } + emit(fullParams) } + .distinctUntilChanged() + .shareIn(scope = scope, started = SharingStarted.Eagerly, replay = 1) + + override val state: MutableStateFlow = + MutableStateFlow(AddToPortfolioManager.State.Loading) + + init { + buildFlow() + .onEach { newState -> state.update { newState } } .flowOn(dispatchers.default) - .stateIn( - scope = scope, - started = SharingStarted.Eagerly, - initialValue = AddToPortfolioManager.State.Init, - ) + .launchIn(scope) + } + + override fun onDismiss() { + onDismiss.trySend(Unit) + } + + override fun onSuccessAdded(result: AddToPortfolioManager.Result) { + onSuccessAdded.trySend(result) + } override fun setTokenNetworks(networks: List) { - _allAvailableNetworks.tryEmit(networks) + updateInternal(networks = networks) + } + + override fun setTokenParams(token: TokenMarketParams) { + updateInternal(token = token) + } + + private fun updateInternal(networks: List? = null, token: TokenMarketParams? = null) { + internalParamsFlow.update { prev -> + val newParams = ParamsInternal( + networks = networks ?: prev.networks, + token = token ?: prev.token, + ) + val shouldReload = prev.networks != newParams.networks || prev.token != newParams.token + if (shouldReload) state.update { AddToPortfolioManager.State.Loading } + return@update newParams + } + } + + private fun buildFlow(): Flow = combine( + flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(), + flow2 = paramsFlow, + ) { balances, (availableNetworks, token) -> + val data = availableToAddDataConverter.convert( + balances = balances, + availableNetworks = availableNetworks.toSet(), + marketParams = token, + ) + AddToPortfolioManager.State.Ready(data) } @AssistedFactory interface Factory : AddToPortfolioManager.Factory { override fun create( scope: CoroutineScope, - token: TokenMarketParams, - analyticsParams: AddToPortfolioManager.AnalyticsParams?, + settings: Settings, + analyticsParams: AnalyticsParams, ): DefaultAddToPortfolioManager } + + private data class ParamsInternal( + val networks: List? = null, + val token: TokenMarketParams? = null, + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt index b10ad7940d..8e86530324 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/DefaultMarketsPortfolioComponent.kt @@ -66,7 +66,6 @@ internal class DefaultMarketsPortfolioComponent @AssistedInject constructor( context = childByContext(componentContext), params = AddToPortfolioComponent.Params( addToPortfolioManager = model.addToPortfolioManager, - callback = model.addToPortfolioCallback, ), ) is MarketsPortfolioRoute.TokenReceive -> tokenReceiveComponentFactory.create( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt index 93b2b5629a..b98f118a73 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioModel.kt @@ -14,9 +14,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.impl.analytics.PortfolioAnalyticsEvent @@ -58,16 +56,18 @@ internal class MarketsPortfolioModel @Inject constructor( private val marketsPortfolioDelegate: MarketsPortfolioDelegate = createMarketsPortfolioDelegate() val bottomSheetNavigation: SlotNavigation = SlotNavigation() - val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { - override fun onDismiss() = bottomSheetNavigation.dismiss() - override fun onSuccess(addedToken: CryptoCurrency) = bottomSheetNavigation.dismiss() - } init { marketsPortfolioDelegate.combineData() .onEach { state.value = it } .flowOn(dispatchers.default) .launchIn(modelScope) + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) } fun setTokenNetworks(networks: List) { @@ -83,9 +83,9 @@ internal class MarketsPortfolioModel @Inject constructor( private fun createAddToPortfolioManager(): AddToPortfolioManager { return addToPortfolioManagerFactory.create( scope = modelScope, - token = params.token, - analyticsParams = params.analyticsParams?.source?.let { AddToPortfolioManager.AnalyticsParams(it) }, - ) + settings = AddToPortfolioManager.Settings.DefaultMarket, + analyticsParams = AddToPortfolioManager.AnalyticsParams(params.analyticsParams?.source), + ).also { addToPortfolioManager -> addToPortfolioManager.setTokenParams(params.token) } } private fun createMarketsPortfolioDelegate(): MarketsPortfolioDelegate { @@ -95,11 +95,14 @@ internal class MarketsPortfolioModel @Inject constructor( tokenActionsHandler = tokenActionsHandler, buttonState = addToPortfolioManager.state.map { state -> when (state) { - is AddToPortfolioManager.State.AvailableToAdd -> { - MyPortfolioUM.Tokens.AddButtonState.Available + is AddToPortfolioManager.State.Ready -> { + if (state.isAvailableToAdd) { + MyPortfolioUM.Tokens.AddButtonState.Available + } else { + MyPortfolioUM.Tokens.AddButtonState.Unavailable + } } - AddToPortfolioManager.State.Init -> MyPortfolioUM.Tokens.AddButtonState.Loading - AddToPortfolioManager.State.NothingToAdd -> MyPortfolioUM.Tokens.AddButtonState.Unavailable + AddToPortfolioManager.State.Loading -> MyPortfolioUM.Tokens.AddButtonState.Loading } }, onAddClick = { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt index f09d27300f..d18755aaf4 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt @@ -74,9 +74,7 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( return addToPortfolioComponentFactory.create( context = childByContext(componentContext), params = AddToPortfolioComponent.Params( - addToPortfolioManager = selectToTokenListComponent.addToPortfolioManager!!, - callback = selectToTokenListComponent.addToPortfolioCallback, - shouldSkipTokenActionsScreen = true, + addToPortfolioManager = selectToTokenListComponent.addToPortfolioManager, ), ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt index e8d885de0e..2292f1bdf8 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt @@ -7,7 +7,6 @@ import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.decompose.ComposableListContentComponent import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.tokenlist.entity.TokenListUM @@ -18,8 +17,7 @@ import kotlinx.coroutines.flow.StateFlow internal interface AvailableSwapPairsComponent : ComposableListContentComponent { val bottomSheetNavigation: SlotNavigation - val addToPortfolioManager: AddToPortfolioManager? - val addToPortfolioCallback: AddToPortfolioComponent.Callback + val addToPortfolioManager: AddToPortfolioManager /** Component factory */ interface Factory : ComponentFactory diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt index 23816a7b3a..347d4ad933 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.Modifier import com.arkivanov.decompose.router.slot.SlotNavigation import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel @@ -26,8 +25,7 @@ internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( private val model: AvailableSwapPairsModel = getOrCreateModel(params) override val bottomSheetNavigation: SlotNavigation get() = model.bottomSheetNavigation - override val addToPortfolioManager: AddToPortfolioManager? get() = model.addToPortfolioManager - override val addToPortfolioCallback: AddToPortfolioComponent.Callback get() = model.addToPortfolioCallback + override val addToPortfolioManager: AddToPortfolioManager get() = model.addToPortfolioManager override val uiState: StateFlow get() = model.state diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index dc0066f473..3068b62dcf 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -41,7 +41,6 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.domain.GetAvailablePairsUseCase import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo import com.tangem.feature.swap.domain.models.domain.SwapPairLeast -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent @@ -66,9 +65,7 @@ import com.tangem.features.swap.SwapFeatureToggles import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.runSuspendCatching -import com.tangem.utils.coroutines.saveIn import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -104,14 +101,12 @@ internal class AvailableSwapPairsModel @Inject constructor( private val allUserWallets = getWalletsUseCase.invokeSync() val bottomSheetNavigation: SlotNavigation = SlotNavigation() - var addToPortfolioManager: AddToPortfolioManager? = null - val addToPortfolioCallback: AddToPortfolioComponent.Callback = object : AddToPortfolioComponent.Callback { - override fun onDismiss() = bottomSheetNavigation.dismiss() - override fun onSuccess(addedToken: CryptoCurrency) { - onTokenAddedToPortfolio(addedToken) - } - } - private val addToPortfolioJobHolder = JobHolder() + val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory + .create( + scope = modelScope, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = ScreensSources.Swap.value), + settings = AddToPortfolioManager.Settings.ChooseToken, + ) private val accountListFlow = getAccountListUseCaseFlow() private val availablePairsByNetworkFlow = MutableStateFlow>(emptyMap()) @@ -159,6 +154,12 @@ internal class AvailableSwapPairsModel @Inject constructor( subscribeOnMarketsUpdates() subscribeOnVisibleMarketItems() } + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { result -> onTokenAddedToPortfolio(result.addedCurrency.currency) } + .launchIn(modelScope) } private fun getAccountListUseCaseFlow(): SharedFlow> { @@ -593,45 +594,35 @@ internal class AvailableSwapPairsModel @Inject constructor( } private fun addToPortfolioItem(item: MarketsListItemUM) { - modelScope.launch { - val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) - ?: searchMarketsListManager.getTokenMarketById(item.id) - ?: return@launch + val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) + ?: searchMarketsListManager.getTokenMarketById(item.id) + ?: return - val param = tokenMarket.toSerializableParam() - val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot } + val param = tokenMarket.toSerializableParam() + val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot } - val networks = tokenMarket.networks?.filter { network -> - BlockchainUtils.isSupportedNetworkId( - networkId = network.networkId, - coinId = tokenMarket.id.value, - contractAddress = network.contractAddress, - excludedBlockchains = excludedBlockchains, - hotExcludedBlockchains = hotWalletExcludedBlockchains, - hasOnlyHotWallets = hasOnlyHotWallets, - ) - }?.map { network -> - TokenMarketInfo.Network( - networkId = network.networkId, - isExchangeable = false, - contractAddress = network.contractAddress, - decimalCount = network.decimalCount, - ) - }.orEmpty() + val networks = tokenMarket.networks?.filter { network -> + BlockchainUtils.isSupportedNetworkId( + networkId = network.networkId, + coinId = tokenMarket.id.value, + contractAddress = network.contractAddress, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = hasOnlyHotWallets, + ) + }?.map { network -> + TokenMarketInfo.Network( + networkId = network.networkId, + isExchangeable = false, + contractAddress = network.contractAddress, + decimalCount = network.decimalCount, + ) + }.orEmpty() - addToPortfolioManager = addToPortfolioManagerFactory - .create( - scope = modelScope, - token = param, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = ScreensSources.Swap.value), - ).apply { - setTokenNetworks(networks) - } + addToPortfolioManager.setTokenNetworks(networks) + addToPortfolioManager.setTokenParams(param) - addToPortfolioManager?.state - ?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd } - ?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) } - }.saveIn(addToPortfolioJobHolder) + bottomSheetNavigation.activate(AddToPortfolioRoute) } private fun subscribeOnVisibleMarketItems() { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt index b92904c2d7..4b97a57ebe 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt @@ -54,9 +54,7 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor( return addToPortfolioComponentFactory.create( context = childByContext(componentContext), params = AddToPortfolioComponent.Params( - addToPortfolioManager = model.addToPortfolioManager!!, - callback = model.addToPortfolioCallback, - shouldSkipTokenActionsScreen = true, + addToPortfolioManager = model.addToPortfolioManager, ), ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index ee77c4eeec..02051e6d74 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -80,10 +80,13 @@ internal class ChooseTokenListItemConverter( clickIntents.onAccountExpandClick(clickedAccount) } } - val fiatAmountStateProvider: ((TotalFiatBalance) -> FiatAmountState?) = if (isSearchingState) { - { _ -> FiatAmountState.Empty } - } else { - { _ -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative) } + val fiatAmountStateProvider: ((TotalFiatBalance) -> FiatAmountState?) = { totalBalance -> + when { + isSearchingState -> FiatAmountState.Empty + !isExpanded -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative) + else -> AccountCryptoPortfolioItemStateConverter + .createFiatAmountState(totalBalance, appCurrency) + } } val converter = AccountCryptoPortfolioItemStateConverter( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt index 12459d97e7..d0fd563a00 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt @@ -10,7 +10,6 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId @@ -23,7 +22,6 @@ import com.tangem.feature.swap.converters.TokensDataConverter import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -73,17 +71,6 @@ internal class ChooseTokenModel @Inject constructor( private val expandedAccountsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) private val onWalletSelected = Channel() - // todo swap call new api result - val addToPortfolioCallback = object : AddToPortfolioComponent.Callback { - override fun onDismiss() = marketBlockDelegate.addToPortfolioSlot.dismiss() - - override fun onSuccess(addedToken: CryptoCurrency) { - val newToken = addedToken to ChooseTokenAnalyticsPayload.IsSearched(isSearchingState) - bridge.onNewTokenAdded(newToken) - marketBlockDelegate.addToPortfolioSlot.dismiss() - } - } - val stateOld: StateFlow = combineUIOld() private val contentState: StateFlow = combineUI() @@ -103,6 +90,27 @@ internal class ChooseTokenModel @Inject constructor( initialValue = ChooseTokenFullUM(initialState.value, contentState.value), ) + init { + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { addedResult -> + val isSearched = ChooseTokenAnalyticsPayload.IsSearched(isSearchingState) + val newToken = addedResult.addedCurrency.currency to isSearched + bridge.onNewTokenAdded(newToken) + val chooseTokenResult = ChooseTokenResult( + currency = addedResult.addedCurrency, + account = addedResult.account, + wallet = addedResult.wallet, + analyticsPayload = setOf(isSearched), + ) + bridge.onCurrencyChosen(chooseTokenResult) + marketBlockDelegate.addToPortfolioSlot.dismiss() + } + .launchIn(modelScope) + } + @Suppress("UnusedPrivateMember") private fun combineUIOld(): StateFlow = combine( flow = bridge.currenciesGroup, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt index 2c66851ed2..36f5f5ebc2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt @@ -20,14 +20,11 @@ import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class MarketBlockDelegate @AssistedInject constructor( @@ -40,12 +37,15 @@ internal class MarketBlockDelegate @AssistedInject constructor( @Assisted private val screensSourcesName: String, ) { - private val addToPortfolioJobHolder = JobHolder() private val visibleMarketItemIds = MutableStateFlow>(emptyList()) private val visibleDefaultMarketItemIds = MutableStateFlow>(emptyList()) val addToPortfolioSlot: SlotNavigation = SlotNavigation() - var addToPortfolioManager: AddToPortfolioManager? = null + val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings.ChooseToken, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName), + ) val marketsStateFlow: Flow = searchQueryState // Switch between default and search market flows @@ -177,45 +177,34 @@ internal class MarketBlockDelegate @AssistedInject constructor( } private fun addToPortfolioItem(item: MarketsListItemUM) { - modelScope.launch { - val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) - ?: searchMarketsListManager.getTokenMarketById(item.id) - ?: return@launch + val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) + ?: searchMarketsListManager.getTokenMarketById(item.id) ?: return - val param = tokenMarket.toSerializableParam() - val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } + val param = tokenMarket.toSerializableParam() + val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot } - val networks = tokenMarket.networks?.filter { network -> - BlockchainUtils.isSupportedNetworkId( - networkId = network.networkId, - coinId = tokenMarket.id.value, - contractAddress = network.contractAddress, - excludedBlockchains = excludedBlockchains, - hotExcludedBlockchains = hotWalletExcludedBlockchains, - hasOnlyHotWallets = hasOnlyHotWallets, - ) - }?.map { network -> - TokenMarketInfo.Network( - networkId = network.networkId, - isExchangeable = false, - contractAddress = network.contractAddress, - decimalCount = network.decimalCount, - ) - }.orEmpty() + val networks = tokenMarket.networks?.filter { network -> + BlockchainUtils.isSupportedNetworkId( + networkId = network.networkId, + coinId = tokenMarket.id.value, + contractAddress = network.contractAddress, + excludedBlockchains = excludedBlockchains, + hotExcludedBlockchains = hotWalletExcludedBlockchains, + hasOnlyHotWallets = hasOnlyHotWallets, + ) + }?.map { network -> + TokenMarketInfo.Network( + networkId = network.networkId, + isExchangeable = false, + contractAddress = network.contractAddress, + decimalCount = network.decimalCount, + ) + }.orEmpty() - addToPortfolioManager = addToPortfolioManagerFactory - .create( - scope = modelScope, - token = param, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName), - ).apply { - setTokenNetworks(networks) - } + addToPortfolioManager.setTokenNetworks(networks) + addToPortfolioManager.setTokenParams(param) - addToPortfolioManager?.state - ?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd } - ?.run { addToPortfolioSlot.activate(AddToPortfolioRoute) } - }.saveIn(addToPortfolioJobHolder) + addToPortfolioSlot.activate(AddToPortfolioRoute) } @AssistedFactory From 58909db262154ece098933f32b5cf96bafaba866 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 15:45:39 +0400 Subject: [PATCH 039/206] Updated on 2026-08-14 --- .../data/account/di/AccountDataModule.kt | 32 ---- .../data/account/di/AccountUtilsModule.kt | 18 +++ .../DefaultAccountsExpandedRepository.kt | 37 ++++- .../repository/AccountsExpandedRepository.kt | 9 ++ .../ChooseTokenExpandedAccountsHolder.kt | 42 ++++++ .../status/utils/ExpandedAccountsHolder.kt | 140 ++++++++++-------- .../utils/MainExpandedAccountsHolder.kt | 31 ++++ .../impl/model/PortfolioListBlockDelegate.kt | 4 +- .../account/AccountDependencies.kt | 4 +- 9 files changed, 214 insertions(+), 103 deletions(-) create mode 100644 data/account/src/main/kotlin/com/tangem/data/account/di/AccountUtilsModule.kt create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ChooseTokenExpandedAccountsHolder.kt create mode 100644 domain/account/status/src/main/java/com/tangem/domain/account/status/utils/MainExpandedAccountsHolder.kt diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt index b2ba244666..668808ef2a 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountDataModule.kt @@ -1,14 +1,9 @@ package com.tangem.data.account.di import android.content.Context -import androidx.datastore.core.DataStoreFactory -import androidx.datastore.dataStoreFile -import com.squareup.moshi.Moshi import com.tangem.data.account.converter.AccountConverterFactoryContainer import com.tangem.data.account.fetcher.DefaultWalletAccountsFetcher -import com.tangem.data.account.repository.AccountsExpandedDTO import com.tangem.data.account.repository.DefaultAccountsCRUDRepository -import com.tangem.data.account.repository.DefaultAccountsExpandedRepository import com.tangem.data.account.store.AccountsResponseStoreFactory import com.tangem.data.account.store.ArchivedAccountsStoreFactory import com.tangem.data.account.tokens.DefaultMainAccountTokensMigration @@ -17,14 +12,9 @@ import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.data.common.cache.etag.ETagsStore import com.tangem.data.common.currency.UserTokensSaver import com.tangem.datasource.api.tangemTech.TangemTechApi -import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.accounts.AccountTokenMigrationStore import com.tangem.datasource.local.datastore.RuntimeStateStore -import com.tangem.datasource.utils.MoshiDataStoreSerializer -import com.tangem.datasource.utils.mapWithStringKeyTypes -import com.tangem.datasource.utils.setTypes import com.tangem.domain.account.repository.AccountsCRUDRepository -import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.account.tokens.MainAccountTokensMigration import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -63,28 +53,6 @@ internal object AccountDataModule { ) } - @Provides - @Singleton - fun provideAccountsExpandedRepository( - @NetworkMoshi moshi: Moshi, - @ApplicationContext context: Context, - appScope: AppCoroutineScope, - ): AccountsExpandedRepository { - val store = DataStoreFactory.create>>( - serializer = MoshiDataStoreSerializer( - moshi = moshi, - types = mapWithStringKeyTypes(valueTypes = setTypes()), - defaultValue = emptyMap(), - ), - produceFile = { context.dataStoreFile(fileName = "account_expanded_store") }, - scope = appScope, - ) - - return DefaultAccountsExpandedRepository( - store = store, - ) - } - @Provides @Singleton fun provideWalletAccountsFetcher(impl: DefaultWalletAccountsFetcher): WalletAccountsFetcher = impl diff --git a/data/account/src/main/kotlin/com/tangem/data/account/di/AccountUtilsModule.kt b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountUtilsModule.kt new file mode 100644 index 0000000000..5a1e61c2bf --- /dev/null +++ b/data/account/src/main/kotlin/com/tangem/data/account/di/AccountUtilsModule.kt @@ -0,0 +1,18 @@ +package com.tangem.data.account.di + +import com.tangem.data.account.repository.DefaultAccountsExpandedRepository +import com.tangem.domain.account.repository.AccountsExpandedRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface AccountUtilsModule { + + @Binds + fun provideAccountsExpandedRepositoryFactory( + factory: DefaultAccountsExpandedRepository.Factory, + ): AccountsExpandedRepository.Factory +} \ No newline at end of file diff --git a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt index e5232c0dc9..2bbaad3697 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/repository/DefaultAccountsExpandedRepository.kt @@ -1,17 +1,28 @@ package com.tangem.data.account.repository +import android.content.Context import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi import com.tangem.data.account.converter.toAccountId +import com.tangem.datasource.di.NetworkMoshi +import com.tangem.datasource.utils.MoshiDataStoreSerializer +import com.tangem.datasource.utils.mapWithStringKeyTypes +import com.tangem.datasource.utils.setTypes import com.tangem.domain.account.models.AccountExpandedState import com.tangem.domain.account.repository.AccountsExpandedRepository import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.AppCoroutineScope +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import javax.inject.Inject -internal class DefaultAccountsExpandedRepository( +internal class DefaultAccountsExpandedRepository constructor( private val store: DataStore>>, ) : AccountsExpandedRepository { @@ -41,7 +52,7 @@ internal class DefaultAccountsExpandedRepository( } override suspend fun clearStore() { - store.updateData { emptyMap() } + store.updateData { map -> map.mapValues { emptySet() } } } override suspend fun update(accountState: AccountExpandedState) { @@ -59,6 +70,28 @@ internal class DefaultAccountsExpandedRepository( map.plus(walletId.stringValue to updatedSet) } } + + internal class Factory @Inject constructor( + @NetworkMoshi private val moshi: Moshi, + @ApplicationContext private val context: Context, + private val appScope: AppCoroutineScope, + ) : AccountsExpandedRepository.Factory { + override fun create(storeFileName: String): DefaultAccountsExpandedRepository { + val store = DataStoreFactory.create>>( + serializer = MoshiDataStoreSerializer( + moshi = moshi, + types = mapWithStringKeyTypes(valueTypes = setTypes()), + defaultValue = emptyMap(), + ), + produceFile = { context.dataStoreFile(fileName = storeFileName) }, + scope = appScope, + ) + + return DefaultAccountsExpandedRepository( + store = store, + ) + } + } } @JsonClass(generateAdapter = true) diff --git a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt index d493fc48c5..85c362e718 100644 --- a/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt +++ b/domain/account/src/main/java/com/tangem/domain/account/repository/AccountsExpandedRepository.kt @@ -12,4 +12,13 @@ interface AccountsExpandedRepository { suspend fun syncStore(walletId: UserWalletId, existAccounts: Set) suspend fun clearStore() suspend fun update(accountState: AccountExpandedState) + + interface Factory { + fun create(storeFileName: String): AccountsExpandedRepository + } + + companion object { + const val MAIN_STORE_FILE_NAME = "account_expanded_store" + const val CHOOSE_TOKEN_FILE_NAME = "choose_token_account_expanded_store" + } } \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ChooseTokenExpandedAccountsHolder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ChooseTokenExpandedAccountsHolder.kt new file mode 100644 index 0000000000..c571c27ac0 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ChooseTokenExpandedAccountsHolder.kt @@ -0,0 +1,42 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.domain.account.repository.AccountsExpandedRepository +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChooseTokenExpandedAccountsHolder @Inject constructor( + private val mainHolder: MainExpandedAccountsHolder, + holderFactory: DefaultExpandedAccountsHolder.Factory, + repositoryFactory: AccountsExpandedRepository.Factory, +) : ExpandedAccountsHolder { + + private val repository: AccountsExpandedRepository = + repositoryFactory.create(AccountsExpandedRepository.CHOOSE_TOKEN_FILE_NAME) + private val defaultHolder: DefaultExpandedAccountsHolder = holderFactory.create(repository) + + override fun expandedAccounts(walletId: UserWalletId): Flow> = flow { + val isStored = repository.expandedAccounts.first()[walletId] != null + + if (isStored) { + emitAll(defaultHolder.expandedAccounts(walletId)) + } else { + val initExpanded = mainHolder.expandedAccounts(walletId).first() + emitAll(defaultHolder.expandedAccounts(walletId, initExpanded)) + } + } + + override fun expandAccount(accountId: AccountId) { + defaultHolder.expandAccount(accountId) + } + + override fun collapseAccount(accountId: AccountId) { + defaultHolder.collapseAccount(accountId) + } +} \ No newline at end of file diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ExpandedAccountsHolder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ExpandedAccountsHolder.kt index 4a33fdf3b8..f0bf98cccd 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ExpandedAccountsHolder.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/ExpandedAccountsHolder.kt @@ -9,20 +9,26 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton -// todo swap separate for main and swap -@Singleton -class ExpandedAccountsHolder @Inject constructor( +interface ExpandedAccountsHolder { + fun expandedAccounts(userWallet: UserWallet): Flow> = expandedAccounts(userWallet.walletId) + fun expandedAccounts(walletId: UserWalletId): Flow> + fun expandAccount(accountId: AccountId) + fun collapseAccount(accountId: AccountId) +} + +class DefaultExpandedAccountsHolder @AssistedInject constructor( private val singleAccountListSupplier: SingleAccountListSupplier, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - private val accountsExpandedRepository: AccountsExpandedRepository, + @Assisted private val accountsExpandedRepository: AccountsExpandedRepository, private val dispatchers: CoroutineDispatcherProvider, ) { @@ -31,71 +37,70 @@ class ExpandedAccountsHolder @Inject constructor( onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - fun expandedAccounts(userWallet: UserWallet): Flow> = expandedAccounts(userWallet.walletId) + fun expandedAccounts(walletId: UserWalletId, initExpanded: Set = emptySet()): Flow> = + channelFlow { + val storedState = accountsExpandedRepository.expandedAccounts + .map { it[walletId] ?: initExpanded.map { id -> AccountExpandedState(id, true) } } + .stateIn(this) - fun expandedAccounts(walletId: UserWalletId): Flow> = channelFlow { - val storedState = accountsExpandedRepository.expandedAccounts - .map { it[walletId].orEmpty() } - .stateIn(this) + val isAccountsMode = isAccountsModeEnabledUseCase.invoke() + .stateIn(this) - val isAccountsMode = isAccountsModeEnabledUseCase.invoke() - .stateIn(this) + val initExpandedState = storedState.value + .mapNotNull { it.takeIf { state -> state.isExpanded }?.accountId } + .toSet() + // main state holder + val expandedAccounts = MutableStateFlow(initExpandedState) + var debounceJob: Job? = null - val initExpandedState = storedState.value - .mapNotNull { it.takeIf { state -> state.isExpanded }?.accountId } - .toSet() - // main state holder - val expandedAccounts = MutableStateFlow(initExpandedState) - var debounceJob: Job? = null - - actionChannel - .filter { (accountId, _) -> accountId.userWalletId == walletId } - .filter { debounceJob?.isActive != true } - .onEach { (accountId, isExpand) -> - debounceJob = launch { delay(DEBOUNCE_MILLIS) } - val newState = AccountExpandedState(accountId, isExpand) - launch { accountsExpandedRepository.update(newState) } - if (isExpand) { - expandedAccounts.update { it.plus(accountId) } - } else { - expandedAccounts.update { it.minus(accountId) } + actionChannel + .filter { (accountId, _) -> accountId.userWalletId == walletId } + .filter { debounceJob?.isActive != true } + .onEach { (accountId, isExpand) -> + debounceJob = launch { delay(DEBOUNCE_MILLIS) } + val newState = AccountExpandedState(accountId, isExpand) + launch { accountsExpandedRepository.update(newState) } + if (isExpand) { + expandedAccounts.update { it.plus(accountId) } + } else { + expandedAccounts.update { it.minus(accountId) } + } } - } - .launchIn(this) + .launchIn(this) - walletAccounts(walletId).onEach { accountList -> - if (!isAccountsModeEnabledUseCase.invokeSync()) { - accountsExpandedRepository.clearStore() - expandedAccounts.update { emptySet() } - return@onEach - } - val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId } - accountsExpandedRepository.syncStore(walletId, idsSet) - - val isSingleAccount = accountList.accounts.size == 1 - val storedMainAccountState = storedState.value - .find { it.accountId == accountList.mainAccount.accountId } - - if (isSingleAccount && storedMainAccountState == null) { - // force expand for single and not stored account - expandedAccounts.update { setOf(accountList.mainAccount.accountId) } - } - }.launchIn(this) - - combine( - flow = expandedAccounts, - flow2 = isAccountsMode, - transform = { expanded, isAccountMode -> - if (isAccountMode) { - channel.send(expanded) - } else { - channel.send(emptySet()) + walletAccounts(walletId).onEach { accountList -> + if (!isAccountsModeEnabledUseCase.invokeSync()) { + accountsExpandedRepository.clearStore() + expandedAccounts.update { emptySet() } + return@onEach } - }, - ).collect() - } - .flowOn(dispatchers.default) - .distinctUntilChanged() + val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId } + accountsExpandedRepository.syncStore(walletId, idsSet) + + val isSingleAccount = accountList.accounts.size == 1 + val storedMainAccountState = storedState.value + .find { it.accountId == accountList.mainAccount.accountId } + + if (isSingleAccount && storedMainAccountState == null) { + // force expand for single and not stored account + expandedAccounts.update { setOf(accountList.mainAccount.accountId) } + } + }.launchIn(this) + + combine( + flow = expandedAccounts, + flow2 = isAccountsMode, + transform = { expanded, isAccountMode -> + if (isAccountMode) { + channel.send(expanded) + } else { + channel.send(emptySet()) + } + }, + ).collect() + } + .flowOn(dispatchers.default) + .distinctUntilChanged() fun expandAccount(accountId: AccountId) { actionChannel.tryEmit(accountId to true) @@ -107,6 +112,11 @@ class ExpandedAccountsHolder @Inject constructor( private fun walletAccounts(walletId: UserWalletId): Flow = singleAccountListSupplier(walletId) + @AssistedFactory + interface Factory { + fun create(accountsExpandedRepository: AccountsExpandedRepository): DefaultExpandedAccountsHolder + } + companion object { private const val DEBOUNCE_MILLIS = 200L } diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/MainExpandedAccountsHolder.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/MainExpandedAccountsHolder.kt new file mode 100644 index 0000000000..95fba2f575 --- /dev/null +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/utils/MainExpandedAccountsHolder.kt @@ -0,0 +1,31 @@ +package com.tangem.domain.account.status.utils + +import com.tangem.domain.account.repository.AccountsExpandedRepository +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MainExpandedAccountsHolder @Inject constructor( + holderFactory: DefaultExpandedAccountsHolder.Factory, + repositoryFactory: AccountsExpandedRepository.Factory, +) : ExpandedAccountsHolder { + + private val repository: AccountsExpandedRepository = repositoryFactory + .create(AccountsExpandedRepository.MAIN_STORE_FILE_NAME) + private val default: DefaultExpandedAccountsHolder = holderFactory.create(repository) + + override fun expandedAccounts(walletId: UserWalletId): Flow> { + return default.expandedAccounts(walletId) + } + + override fun expandAccount(accountId: AccountId) { + default.expandAccount(accountId) + } + + override fun collapseAccount(accountId: AccountId) { + default.collapseAccount(accountId) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt index 64558e8d15..9e45ac853a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt @@ -3,7 +3,7 @@ package com.tangem.feature.swap.choosetoken.impl.model import com.tangem.common.ui.tokens.TokenConverterParams import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier -import com.tangem.domain.account.status.utils.ExpandedAccountsHolder +import com.tangem.domain.account.status.utils.ChooseTokenExpandedAccountsHolder import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus @@ -23,7 +23,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* internal class PortfolioListBlockDelegate @AssistedInject constructor( - private val expandedAccountsHolder: ExpandedAccountsHolder, + private val expandedAccountsHolder: ChooseTokenExpandedAccountsHolder, private val settingContext: SettingContextUseCase, private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, private val getWalletsUseCase: GetWalletsUseCase, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/AccountDependencies.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/AccountDependencies.kt index d30b9a4165..b252ecce55 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/AccountDependencies.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/account/AccountDependencies.kt @@ -3,14 +3,14 @@ package com.tangem.feature.wallet.presentation.account import com.tangem.core.decompose.di.ModelScoped import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusSupplier -import com.tangem.domain.account.status.utils.ExpandedAccountsHolder +import com.tangem.domain.account.status.utils.MainExpandedAccountsHolder import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase import javax.inject.Inject @ModelScoped internal class AccountDependencies @Inject constructor( val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - val expandedAccountsHolder: ExpandedAccountsHolder, + val expandedAccountsHolder: MainExpandedAccountsHolder, val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, val singleAccountStatusSupplier: SingleAccountStatusSupplier, ) \ No newline at end of file From 08ac0e23d85f143c11ff15e135253ca716f39208 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 15:31:19 +0300 Subject: [PATCH 040/206] Updated on 2026-08-14 --- .../DefaultDynamicAddressesRepository.kt | 2 +- .../DefaultMultiNetworkStatusFetcherTest.kt | 213 ++++++++++++++++++ .../DisableDynamicAddressesUseCase.kt | 2 +- .../DynamicAddressesSupportedBlockchains.kt | 2 +- 4 files changed, 216 insertions(+), 3 deletions(-) diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index ce82539915..56ff5bfdb2 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -150,7 +150,7 @@ internal class DefaultDynamicAddressesRepository( } private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean { - // Check if WalletManager is already in XPUB mode (dynamic addresses was previously enabled on this device) + // Check if WalletManager is already in XPUB mode (dynamic addresses were previously enabled on this device) return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null } diff --git a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt index 8a1a396ed2..f355cc27c1 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcherTest.kt @@ -265,6 +265,219 @@ internal class DefaultMultiNetworkStatusFetcherTest { coVerify(inverse = true) { commonNetworkStatusFetcher.fetch(any(), any(), any(), any()) } } + @Test + fun `fetch passes xpub to correct network and null to others`() = runTest { + // Arrange + val xpub = "xpub_test_eth" + val params = setupTwoNetworkParams() + coEvery { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) } returns mapOf(ethereum.network to xpub) + + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = xpub, + ) + } returns Either.Right(Unit) + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } returns Either.Right(Unit) + + // Act + val actual = fetcher(params) + + // Assert + val expected = Either.Right(Unit) + assertEither(actual, expected) + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = xpub, + ) + } + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } + } + + @Test + fun `fetch continues with null xpub for all networks if getXpubs throws`() = runTest { + // Arrange + val params = setupTwoNetworkParams() + coEvery { + dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) + } throws RuntimeException("XPUB derivation failed") + + val fetchResult = Either.Right(Unit) + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = null, + ) + } returns fetchResult + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } returns fetchResult + + // Act + val actual = fetcher(params) + + // Assert + val expected = Either.Right(Unit) + assertEither(actual, expected) + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = null, + ) + } + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } + // getXpubs failure must not degrade network status to OnlyCache + coVerify(inverse = true) { networksStatusesStore.setSourceAsOnlyCache(userWalletId = any(), networks = any()) } + } + + @Test + fun `fetch passes xpubs to all networks returned by getXpubs`() = runTest { + // Arrange + val ethXpub = "xpub_eth" + val adaXpub = "xpub_ada" + val params = setupTwoNetworkParams() + coEvery { + dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) + } returns mapOf(ethereum.network to ethXpub, cardano.network to adaXpub) + + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = ethXpub, + ) + } returns Either.Right(Unit) + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = adaXpub, + ) + } returns Either.Right(Unit) + + // Act + val actual = fetcher(params) + + // Assert + val expected = Either.Right(Unit) + assertEither(actual, expected) + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = ethXpub, + ) + } + coVerify(exactly = 1) { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = adaXpub, + ) + } + } + + @Test + fun `fetch calls getXpubs with exactly the networks from params`() = runTest { + // Arrange + val params = setupTwoNetworkParams() + coEvery { + commonNetworkStatusFetcher.fetch(userWalletId = any(), network = any(), networkCurrencies = any(), xpub = any()) + } returns Either.Right(Unit) + + // Act + fetcher(params) + + // Assert + coVerify(exactly = 1) { dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) } + } + + @Test + fun `fetch failure if network fetch fails when xpub is provided`() = runTest { + // Arrange + val xpub = "xpub_eth" + val params = setupTwoNetworkParams() + coEvery { + dynamicAddressesInitializer.getXpubs(params.userWalletId, params.networks) + } returns mapOf(ethereum.network to xpub) + + val fetchFailure = Either.Left(IllegalStateException()) + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = ethereum.network, + networkCurrencies = setOf(ethereum), + xpub = xpub, + ) + } returns fetchFailure + coEvery { + commonNetworkStatusFetcher.fetch( + userWalletId = params.userWalletId, + network = cardano.network, + networkCurrencies = setOf(cardano), + xpub = null, + ) + } returns Either.Right(Unit) + + // Act + val actual = fetcher(params) + + // Assert + val expected = Either.Left(IllegalStateException("Failed to fetch network statuses")) + assertEither(actual, expected) + } + + private fun setupTwoNetworkParams(): MultiNetworkStatusFetcher.Params { + val params = MultiNetworkStatusFetcher.Params( + userWalletId = userWalletId, + networks = setOf(ethereum.network, cardano.network), + ) + coEvery { cardCryptoCurrencyFactory.create(params.userWalletId, params.networks) } returns mapOf( + ethereum.network to listOf(ethereum), + cardano.network to listOf(cardano), + ) + return params + } + private companion object { val userWalletId = UserWalletId("011") val cryptoCurrencyFactory = MockCryptoCurrencyFactory() diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt index d0bcbb4660..17d8139c00 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DisableDynamicAddressesUseCase.kt @@ -11,7 +11,7 @@ class DisableDynamicAddressesUseCase( /** * Returns true when consolidation is required before disabling (non-base balances exist), - * or false when dynamic addresses was disabled immediately. + * or false when dynamic addresses were disabled immediately. */ suspend operator fun invoke(userWalletId: UserWalletId, network: Network): Either = Either.catch { diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt index c3df141dd3..9ba156ddaa 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesSupportedBlockchains.kt @@ -7,7 +7,7 @@ import com.tangem.blockchainsdk.utils.toNetworkId * List of blockchains that support Dynamic Addresses (XPUB-based multi-address mode). * Must match [Blockchain.isBip44DerivationStyleXPUB] from blockchain-sdk minus Kaspa (deferred). * - * Dynamic addresses is NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses. + * Dynamic addresses are NOT used for Legacy (m/44' for BTC/LTC) or Taproot (m/86') addresses. * Only the default derivation style per blockchain is supported. */ object DynamicAddressesSupportedBlockchains { From 4bf6ea717b5c7821238d57a5008084a1059a9e2e Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 14:55:39 +0100 Subject: [PATCH 041/206] Updated on 2026-08-14 --- .../impl/presentation/model/StakingModel.kt | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index a2081de45c..a004c90be2 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -307,10 +307,10 @@ internal class StakingModel @Inject constructor( private val sendTransactionJobHolder = JobHolder() private val stepChangesJobHolder = JobHolder() private val balanceHidingJobHolder = JobHolder() + private val currencyStatusJobHolder = JobHolder() init { subscribeOnSelectedAppCurrency() - subscribeOnCurrencyStatusUpdates() stateController.initializeWithUserWallet(userWallet) } @@ -322,6 +322,7 @@ internal class StakingModel @Inject constructor( sendTransactionJobHolder.cancel() stepChangesJobHolder.cancel() balanceHidingJobHolder.cancel() + currencyStatusJobHolder.cancel() } override fun onBackClick() { @@ -1243,21 +1244,24 @@ internal class StakingModel @Inject constructor( isAnyTokenStaked = isAnyTokenStakedUseCase(userWalletId).getOrNull() == true } - private fun subscribeOnCurrencyStatusUpdates() { - getAccountCurrencyStatusUseCase( - userWalletId = params.userWalletId, - currency = params.cryptoCurrency, - ) - .conflate() - .distinctUntilChanged() - .filter { value.currentStep == StakingStep.InitialInfo || isTopHeatupCase() } - .onEach { (maybeAccount, maybeStatus) -> - isAccountsModeEnabled = isAccountsModeEnabledUseCase.invokeSync() - account = maybeAccount - onDataLoaded(maybeStatus) - } - .flowOn(dispatchers.main) - .launchIn(modelScope) + private fun subscribeOnCurrencyStatusUpdatesIfNeeded() { + if (currencyStatusJobHolder.isActive.not()) { + val job = getAccountCurrencyStatusUseCase( + userWalletId = params.userWalletId, + currency = params.cryptoCurrency, + ) + .conflate() + .distinctUntilChanged() + .filter { value.currentStep == StakingStep.InitialInfo || isTopHeatupCase() } + .onEach { (maybeAccount, maybeStatus) -> + isAccountsModeEnabled = isAccountsModeEnabledUseCase.invokeSync() + account = maybeAccount + onDataLoaded(maybeStatus) + } + .flowOn(dispatchers.main) + .launchIn(modelScope) + currencyStatusJobHolder.update(job) + } } private suspend fun onDataLoaded(status: CryptoCurrencyStatus) { @@ -1318,6 +1322,7 @@ internal class StakingModel @Inject constructor( .distinctUntilChanged() .onEach { maybeAppCurrency -> appCurrency = maybeAppCurrency.getOrElse { AppCurrency.Default } + subscribeOnCurrencyStatusUpdatesIfNeeded() } .flowOn(dispatchers.main) .launchIn(modelScope) From b07a9889f133671f9aadee97d5aa024d739ac8a5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 14 Apr 2026 17:15:55 +0100 Subject: [PATCH 042/206] Updated on 2026-08-14 --- .../impl/presentation/model/StakingModel.kt | 86 ++++++++----------- 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index a004c90be2..4697e51e82 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -109,7 +109,6 @@ import kotlinx.coroutines.runBlocking import java.math.BigDecimal import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject -import kotlin.properties.Delegates @Suppress("LargeClass", "TooManyFunctions", "LongParameterList") @Stable @@ -121,7 +120,7 @@ internal class StakingModel @Inject constructor( private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, - private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val sendTransactionUseCase: SendTransactionUseCase, private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, @@ -204,7 +203,13 @@ internal class StakingModel @Inject constructor( getUserWalletUseCase(userWalletId).getOrNull(), ) { "No wallet found for id: $userWalletId" } } - private var appCurrency: AppCurrency by Delegates.notNull() + private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> + maybeAppCurrency.getOrElse { AppCurrency.Default } + }.stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = AppCurrency.Default, + ) private val balancesToShow: List get() { @@ -307,10 +312,9 @@ internal class StakingModel @Inject constructor( private val sendTransactionJobHolder = JobHolder() private val stepChangesJobHolder = JobHolder() private val balanceHidingJobHolder = JobHolder() - private val currencyStatusJobHolder = JobHolder() init { - subscribeOnSelectedAppCurrency() + subscribeOnCurrencyStatusUpdates() stateController.initializeWithUserWallet(userWallet) } @@ -322,7 +326,6 @@ internal class StakingModel @Inject constructor( sendTransactionJobHolder.cancel() stepChangesJobHolder.cancel() balanceHidingJobHolder.cancel() - currencyStatusJobHolder.cancel() } override fun onBackClick() { @@ -364,7 +367,7 @@ internal class StakingModel @Inject constructor( clickIntents = this@StakingModel, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, userWalletProvider = Provider { userWallet }, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, isBalanceHidden = isBalanceHiddenFlow.value, isAccountsModeEnabled = isAccountsModeEnabled, account = account, @@ -398,7 +401,7 @@ internal class StakingModel @Inject constructor( stateController.update( SetConfirmationStateLoadingTransformer( integration = integration, - appCurrency = appCurrency, + appCurrency = currentAppCurrency.value, cryptoCurrency = cryptoCurrencyStatus.currency, ), ) @@ -407,7 +410,7 @@ internal class StakingModel @Inject constructor( onStakingFee = { gasEstimate, isFeeApproximate -> stateController.update( SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = gasEstimate, isFeeApproximate = isFeeApproximate, @@ -432,7 +435,7 @@ internal class StakingModel @Inject constructor( onApprovalFee = { fee -> stateController.update( SetConfirmationStateAssentApprovalTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = fee, cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -543,7 +546,7 @@ internal class StakingModel @Inject constructor( stateController.updateAll( SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus), SetConfirmationStateAssentTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = increasedFee, isFeeApproximate = isFeeApproximate, @@ -791,7 +794,7 @@ internal class StakingModel @Inject constructor( stateController.update( ShowApprovalBottomSheetTransformer( userWallet = userWallet, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ) { @@ -843,7 +846,7 @@ internal class StakingModel @Inject constructor( ) stateController.update( SetConfirmationStateAssentApprovalTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = TransactionFee.Single(fee), cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -872,7 +875,7 @@ internal class StakingModel @Inject constructor( ) stateController.update( SetConfirmationStateAssentApprovalTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = TransactionFee.Single(fee), cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -930,7 +933,7 @@ internal class StakingModel @Inject constructor( stateController.update( AddStakingNotificationsTransformer( cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, isAccountInitializedProvider = Provider { isAccountInitialized }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, currencyWarning = currencyWarning, @@ -1150,7 +1153,7 @@ internal class StakingModel @Inject constructor( ifRight = { fee -> stateController.update( SetFeeToTonInitializeBottomSheetTransformer( - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, fee = fee.normal, isFeeApproximate = false, @@ -1244,24 +1247,21 @@ internal class StakingModel @Inject constructor( isAnyTokenStaked = isAnyTokenStakedUseCase(userWalletId).getOrNull() == true } - private fun subscribeOnCurrencyStatusUpdatesIfNeeded() { - if (currencyStatusJobHolder.isActive.not()) { - val job = getAccountCurrencyStatusUseCase( - userWalletId = params.userWalletId, - currency = params.cryptoCurrency, - ) - .conflate() - .distinctUntilChanged() - .filter { value.currentStep == StakingStep.InitialInfo || isTopHeatupCase() } - .onEach { (maybeAccount, maybeStatus) -> - isAccountsModeEnabled = isAccountsModeEnabledUseCase.invokeSync() - account = maybeAccount - onDataLoaded(maybeStatus) - } - .flowOn(dispatchers.main) - .launchIn(modelScope) - currencyStatusJobHolder.update(job) - } + private fun subscribeOnCurrencyStatusUpdates() { + getAccountCurrencyStatusUseCase( + userWalletId = params.userWalletId, + currency = params.cryptoCurrency, + ) + .conflate() + .distinctUntilChanged() + .filter { value.currentStep == StakingStep.InitialInfo || isTopHeatupCase() } + .onEach { (maybeAccount, maybeStatus) -> + isAccountsModeEnabled = isAccountsModeEnabledUseCase.invokeSync() + account = maybeAccount + onDataLoaded(maybeStatus) + } + .flowOn(dispatchers.main) + .launchIn(modelScope) } private suspend fun onDataLoaded(status: CryptoCurrencyStatus) { @@ -1307,7 +1307,7 @@ internal class StakingModel @Inject constructor( transformer = HideBalanceStateTransformer( isBalanceHidden = settings.isBalanceHidden, cryptoCurrencyStatus = cryptoCurrencyStatus, - appCurrency = appCurrency, + appCurrency = currentAppCurrency.value, ), ) } @@ -1316,18 +1316,6 @@ internal class StakingModel @Inject constructor( .saveIn(balanceHidingJobHolder) } - private fun subscribeOnSelectedAppCurrency() { - getSelectedAppCurrencyUseCase() - .conflate() - .distinctUntilChanged() - .onEach { maybeAppCurrency -> - appCurrency = maybeAppCurrency.getOrElse { AppCurrency.Default } - subscribeOnCurrencyStatusUpdatesIfNeeded() - } - .flowOn(dispatchers.main) - .launchIn(modelScope) - } - private fun subscribeOnStepChanges(status: CryptoCurrencyStatus) { uiState .distinctUntilChangedBy { it.currentStep } @@ -1384,7 +1372,7 @@ internal class StakingModel @Inject constructor( isAnyTokenStaked = isAnyTokenStaked, cryptoCurrencyStatus = status, userWalletProvider = Provider { userWallet }, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, balancesToShowProvider = Provider { balancesToShow }, isAccountsModeEnabled = isAccountsModeEnabled, account = account, @@ -1422,7 +1410,7 @@ internal class StakingModel @Inject constructor( clickIntents = this, cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, userWalletProvider = Provider { userWallet }, - appCurrencyProvider = Provider { appCurrency }, + appCurrencyProvider = Provider { currentAppCurrency.value }, isAccountsModeEnabled = isAccountsModeEnabled, isBalanceHidden = isBalanceHiddenFlow.value, account = account, From 650fa3c5782ac5def7860942211689ac2404c918 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Apr 2026 10:12:18 +0200 Subject: [PATCH 043/206] Updated on 2026-08-14 --- .../list/DefaultMarketsTokenListComponent.kt | 4 +- .../news/list/DefaultNewsListComponent.kt | 5 +- .../feed/ui/market/list/MarketsList.kt | 105 +++++++++++------ .../list/components/MarketsListLazyColumn.kt | 6 +- .../feed/ui/market/list/components/Options.kt | 87 ++++++++------ .../feed/ui/news/list/NewsListContent.kt | 108 ++++++++++++++---- .../list/components/NewsListLazyColumn.kt | 16 ++- 7 files changed, 229 insertions(+), 102 deletions(-) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt index 0fd788caa3..2d69602f84 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt @@ -1,6 +1,5 @@ package com.tangem.features.feed.components.market.list -import androidx.compose.animation.core.EaseOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -65,9 +64,8 @@ internal class DefaultMarketsTokenListComponent( .hazeEffectTangem { progressive = HazeProgressive.verticalGradient( startIntensity = .55f, - endIntensity = 0f, + endIntensity = .2f, preferPerformance = true, - easing = EaseOut, ) }, startContent = { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt index 58a30e4a3a..b93cac2f9f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -1,6 +1,5 @@ package com.tangem.features.feed.components.news.list -import androidx.compose.animation.core.EaseOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding @@ -52,10 +51,10 @@ internal class DefaultNewsListComponent( modifier = Modifier.hazeEffectTangem { progressive = HazeProgressive.verticalGradient( startIntensity = .55f, - endIntensity = 0f, + endIntensity = .2f, preferPerformance = true, - easing = EaseOut, ) + backgroundColor = background }, title = resourceReference(R.string.common_news), type = TangemTopBarType.BottomSheet, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt index cafab387b7..fb12e37421 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/MarketsList.kt @@ -8,13 +8,17 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.common.ui.markets.preview.MarketChartListItemPreviewDataProvider import com.tangem.core.ui.components.* @@ -26,9 +30,9 @@ import com.tangem.core.ui.components.fields.TangemSearchBarDefaults import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.event.consumedEvent -import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe +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 @@ -104,33 +108,43 @@ internal fun TopBarWithSearch( @Composable internal fun MarketsList(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value - Column( - modifier = modifier - .fillMaxSize() - .imePadding() - .drawBehind { drawRect(background) }, - ) { - Content(state = state, contentPadding = contentPadding) + // should use here new overrided haze state cause on level upper already applied + CompositionLocalProvider(LocalHazeState provides rememberHazeState()) { + Column( + modifier = modifier + .fillMaxSize() + .imePadding() + .drawBehind { drawRect(background) }, + ) { + Content(state = state, contentPadding = contentPadding) + } + MarketsListSortByBottomSheet(config = state.sortByBottomSheet) + KeyboardEvents(isSortByBottomSheetShown = state.sortByBottomSheet.isShown) } - MarketsListSortByBottomSheet(config = state.sortByBottomSheet) - KeyboardEvents(isSortByBottomSheetShown = state.sortByBottomSheet.isShown) } @Suppress("LongMethod") @Composable private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) { - val isRedesignEnabled = LocalRedesignEnabled.current - - val hazeState = rememberHazeState() - - val strokeColor = if (isRedesignEnabled) { - TangemTheme.colors2.border.neutral.primary + if (LocalRedesignEnabled.current) { + ContentV2( + contentPadding = contentPadding, + state = state, + ) } else { - TangemTheme.colors.stroke.primary + ContentV1( + contentPadding = contentPadding, + state = state, + modifier = modifier, + ) } +} +@Suppress("LongMethod") +@Composable +private fun ColumnScope.ContentV1(contentPadding: PaddingValues, state: MarketsListUM, modifier: Modifier = Modifier) { + val strokeColor = TangemTheme.colors.stroke.primary val scrolledState = remember { mutableStateOf(false) } - Column(modifier.padding(horizontal = TangemTheme.dimens.size16)) { SpacerH(contentPadding.calculateTopPadding()) AnimatedVisibility( @@ -151,19 +165,12 @@ private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsLis Column { AnimatedVisibility(!state.isInSearchMode && !state.marketsSearchBar.shouldAlwaysShowSearchBar) { Options( - modifier = Modifier.padding( - bottom = if (isRedesignEnabled) { - TangemTheme.dimens2.x2 - } else { - TangemTheme.dimens.spacing12 - }, - ), + modifier = Modifier.padding(bottom = TangemTheme.dimens.spacing12), sortByTypeUM = state.selectedSortBy, trendInterval = state.selectedInterval, onIntervalClick = state.onIntervalClick, onSortByClick = state.onSortByButtonClick, sortMenuUM = state.sortByMenuUM, - hazeState = hazeState, ) } } @@ -186,24 +193,57 @@ private fun ColumnScope.Content(contentPadding: PaddingValues, state: MarketsLis }, ) ItemsList( - modifier = Modifier.conditionalCompose( - condition = isRedesignEnabled, - modifier = { - hazeSourceTangem(zIndex = 0f, state = hazeState) - }, - ), scrolledState = scrolledState, isInSearchMode = state.isInSearchMode, state = state.list, ) } +@Suppress("LongMethod") +@Composable +private fun ColumnScope.ContentV2(contentPadding: PaddingValues, state: MarketsListUM) { + val scrolledState = remember { mutableStateOf(false) } + var optionsHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Box(modifier = Modifier.fillMaxSize()) { + ItemsList( + topContentPadding = contentPadding.calculateTopPadding() + optionsHeight, + modifier = Modifier + .align(Alignment.TopStart) + .hazeSourceTangem(zIndex = 1f), + scrolledState = scrolledState, + isInSearchMode = state.isInSearchMode, + state = state.list, + ) + Options( + modifier = Modifier + .align(Alignment.TopStart) + .padding(bottom = TangemTheme.dimens2.x4, top = contentPadding.calculateTopPadding()) + .padding(horizontal = TangemTheme.dimens2.x4) + .onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { + with(density) { + optionsHeight = coordinates.size.height.toDp() + } + } + }, + sortByTypeUM = state.selectedSortBy, + trendInterval = state.selectedInterval, + onIntervalClick = state.onIntervalClick, + onSortByClick = state.onSortByButtonClick, + sortMenuUM = state.sortByMenuUM, + ) + } +} + @Composable private fun ItemsList( scrolledState: MutableState, isInSearchMode: Boolean, state: ListUM, modifier: Modifier = Modifier, + topContentPadding: Dp = 0.dp, ) { val searchLazyListState = rememberLazyListState() val mainLazyListState = rememberLazyListState() @@ -229,6 +269,7 @@ private fun ItemsList( } MarketsListLazyColumn( + topContentPadding = topContentPadding, modifier = modifier, state = state, isInSearchMode = isInSearchMode, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt index 3f7a0fccf5..13eff7a17e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/MarketsListLazyColumn.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder @@ -43,6 +44,7 @@ internal fun MarketsListLazyColumn( isInSearchMode: Boolean, lazyListState: LazyListState, modifier: Modifier = Modifier, + topContentPadding: Dp = 0.dp, ) { val isRedesignEnabled = LocalRedesignEnabled.current val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } @@ -66,7 +68,7 @@ internal fun MarketsListLazyColumn( LazyColumn( modifier = modifier, state = rememberLazyListState(), - contentPadding = PaddingValues(bottom = bottomBarHeight), + contentPadding = PaddingValues(bottom = bottomBarHeight, top = topContentPadding), userScrollEnabled = false, ) { items(count = 100, key = { it }) { @@ -77,7 +79,7 @@ internal fun MarketsListLazyColumn( LazyColumn( modifier = modifier.testTag(MarketsTestTags.TOKENS_LIST), state = lazyListState, - contentPadding = PaddingValues(bottom = bottomBarHeight), + contentPadding = PaddingValues(bottom = bottomBarHeight, top = topContentPadding), userScrollEnabled = true, ) { // ATTENTION! There should be no elements with a string key value except MarketsListItem! diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt index 5dc049b114..191c6db56d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/list/components/Options.kt @@ -1,5 +1,6 @@ package com.tangem.features.feed.ui.market.list.components +import androidx.compose.animation.core.EaseOut import androidx.compose.foundation.layout.* import androidx.compose.material3.Text import androidx.compose.runtime.* @@ -19,13 +20,14 @@ import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.tabs.TangemSegmentUM import com.tangem.core.ui.ds.tabs.TangemSegmentedPicker import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByMenuUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM -import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeProgressive import kotlinx.collections.immutable.persistentListOf import com.tangem.core.ui.ds.button.TangemButtonIconPosition as RedesignTangemButtonIconPosition @@ -35,7 +37,6 @@ internal fun Options( sortMenuUM: SortByMenuUM, sortByTypeUM: SortByTypeUM, trendInterval: MarketsListUM.TrendInterval, - hazeState: HazeState, onSortByClick: () -> Unit, onIntervalClick: (MarketsListUM.TrendInterval) -> Unit, modifier: Modifier = Modifier, @@ -46,7 +47,6 @@ internal fun Options( trendInterval = trendInterval, onIntervalClick = onIntervalClick, modifier = modifier, - hazeState = hazeState, ) } else { OptionsV1( @@ -116,11 +116,11 @@ private fun OptionsV1( private fun OptionsV2( sortMenuUM: SortByMenuUM, trendInterval: MarketsListUM.TrendInterval, - hazeState: HazeState, onIntervalClick: (MarketsListUM.TrendInterval) -> Unit, modifier: Modifier = Modifier, ) { var isShowDropdownMenu by rememberSaveable { mutableStateOf(false) } + val background = LocalMainBottomSheetColor.current.value val segmentItems = remember { persistentListOf( @@ -139,42 +139,55 @@ private fun OptionsV2( ) } - Row( + Box( modifier = modifier - .height(IntrinsicSize.Max) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + .fillMaxWidth() + .wrapContentHeight(align = Alignment.Top), ) { - PrimaryInverseTangemButton( - onClick = { - isShowDropdownMenu = true - }, - iconPosition = RedesignTangemButtonIconPosition.End, - tangemIconUM = TangemIconUM.Icon( - iconRes = R.drawable.ic_chewron_down_20, - tintReference = { TangemTheme.colors2.graphic.neutral.primary }, - ), - text = sortMenuUM.selectedOption.text, - size = TangemButtonSize.X9, - shape = TangemButtonShape.Rounded, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .hazeEffectTangem { + progressive = HazeProgressive.verticalGradient( + startIntensity = .2f, + endIntensity = 0f, + easing = EaseOut, + preferPerformance = true, + ) + backgroundColor = background + }, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PrimaryInverseTangemButton( + onClick = { isShowDropdownMenu = true }, + iconPosition = RedesignTangemButtonIconPosition.End, + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_chewron_down_20, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + text = sortMenuUM.selectedOption.text, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + ) - TangemSegmentedPicker( - items = segmentItems, - initialSelectedItem = segmentItems.firstOrNull { it.id == trendInterval.name }, - isFixed = false, - isAltSurface = true, - minSegmentWidth = 54.dp, - onClick = { segment -> onIntervalClick(MarketsListUM.TrendInterval.valueOf(segment.id)) }, + TangemSegmentedPicker( + items = segmentItems, + initialSelectedItem = segmentItems.firstOrNull { it.id == trendInterval.name }, + isFixed = false, + isAltSurface = true, + minSegmentWidth = 54.dp, + onClick = { segment -> onIntervalClick(MarketsListUM.TrendInterval.valueOf(segment.id)) }, + ) + } + + SortByMenu( + sortMenuUM = sortMenuUM, + showDropdownMenu = isShowDropdownMenu, + onDropdownDismiss = { isShowDropdownMenu = false }, + modifier = Modifier + .align(Alignment.TopStart) + .hazeEffectTangem { blurRadius = 10.dp }, ) } - - SortByMenu( - sortMenuUM = sortMenuUM, - showDropdownMenu = isShowDropdownMenu, - onDropdownDismiss = { isShowDropdownMenu = false }, - modifier = Modifier.hazeEffectTangem(hazeState) { - blurRadius = 10.dp - }, - ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt index 933d5fe2a9..ab551d070a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt @@ -1,37 +1,54 @@ package com.tangem.features.feed.ui.news.list +import androidx.compose.animation.core.EaseOut import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.chip.Chip import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.ds.tabs.TangemTab import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.res.LocalMainBottomSheetColor -import com.tangem.core.ui.res.LocalRedesignEnabled -import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.* import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import com.tangem.features.feed.ui.news.list.components.NewsListLazyColumn import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListUM +import dev.chrisbanes.haze.HazeProgressive import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableSet @Composable internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + NewsListContentV2( + contentPadding = contentPadding, + state = state, + ) + } else { + NewsListContentV1( + contentPadding = contentPadding, + state = state, + modifier = modifier, + ) + } +} + +@Composable +internal fun NewsListContentV1(contentPadding: PaddingValues, state: NewsListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value - val isRedesignEnabled = LocalRedesignEnabled.current val lazyListState = rememberLazyListState() Column( @@ -48,17 +65,7 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m items = state.filters, key = { it.id }, ) { filter -> - if (isRedesignEnabled) { - TangemTab( - text = filter.text, - isChecked = filter.isSelected, - onCheckedChange = { - filter.onClick() - }, - ) - } else { - Chip(state = filter) - } + Chip(state = filter) } } @@ -73,6 +80,67 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m } } +@Composable +internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM) { + val background = LocalMainBottomSheetColor.current.value + val lazyListState = rememberLazyListState() + var chipsHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + ) { + NewsListLazyColumn( + topContentPadding = contentPadding.calculateTopPadding() + 16.dp + chipsHeight, + modifier = Modifier + .hazeSourceTangem(zIndex = 0f) + .align(Alignment.TopStart), + newsListState = state.newsListState, + listOfArticles = state.listOfArticles, + lazyListState = lazyListState, + onArticleClick = state.onArticleClick, + ) + LazyRow( + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = contentPadding.calculateTopPadding(), bottom = TangemTheme.dimens2.x4) + .onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { + with(density) { + chipsHeight = coordinates.size.height.toDp() + } + } + } + .hazeEffectTangem { + progressive = HazeProgressive.verticalGradient( + startIntensity = .2f, + endIntensity = 0f, + easing = EaseOut, + preferPerformance = true, + ) + backgroundColor = background + }, + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = state.filters, + key = { it.id }, + ) { filter -> + TangemTab( + text = filter.text, + isChecked = filter.isSelected, + onCheckedChange = { + filter.onClick() + }, + ) + } + } + } +} + @Suppress("LongMethod") @Preview(showBackground = true) @Composable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt index 177f191773..d9262be19c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/components/NewsListLazyColumn.kt @@ -15,15 +15,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.tangem.features.feed.ui.feed.components.articles.ArticleCard -import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM -import com.tangem.features.feed.ui.feed.components.articles.DefaultLoadingArticle import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.list.InfiniteListHandler import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.feed.components.articles.ArticleCard +import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM +import com.tangem.features.feed.ui.feed.components.articles.DefaultLoadingArticle import com.tangem.features.feed.ui.news.list.state.NewsListState import kotlinx.collections.immutable.ImmutableList @@ -35,6 +36,8 @@ internal fun NewsListLazyColumn( newsListState: NewsListState, lazyListState: LazyListState, onArticleClick: (Int) -> Unit, + modifier: Modifier = Modifier, + topContentPadding: Dp = 0.dp, ) { val screenState by remember(listOfArticles, newsListState) { derivedStateOf { @@ -48,6 +51,7 @@ internal fun NewsListLazyColumn( } AnimatedContent( + modifier = modifier, transitionSpec = { fadeIn() togetherWith fadeOut() }, @@ -57,6 +61,7 @@ internal fun NewsListLazyColumn( when (state) { NewsListScreenState.Content -> { Content( + topContentPadding = topContentPadding, listOfArticles = listOfArticles, newsListState = newsListState, lazyListState = lazyListState, @@ -66,7 +71,7 @@ internal fun NewsListLazyColumn( NewsListScreenState.InitialLoading -> { LazyColumn( state = rememberLazyListState(), - contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp, top = topContentPadding), userScrollEnabled = false, ) { items( @@ -100,11 +105,12 @@ private fun Content( lazyListState: LazyListState, onArticleClick: (Int) -> Unit, modifier: Modifier = Modifier, + topContentPadding: Dp = 0.dp, ) { LazyColumn( modifier = modifier, state = lazyListState, - contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp), + contentPadding = PaddingValues(bottom = 16.dp, start = 16.dp, end = 16.dp, top = topContentPadding), userScrollEnabled = true, ) { items( From 236794fa007667db606737e9b5f3a544e2ff9723 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Apr 2026 13:53:26 +0500 Subject: [PATCH 044/206] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 11 +++ .../tangem/tap/routing/utils/ChildFactory.kt | 8 +- .../tap/routing/utils/DeepLinkFactory.kt | 5 +- .../tap/routing/utils/DeepLinkFactoryTest.kt | 11 ++- .../com/tangem/common/routing/AppRoute.kt | 11 ++- .../tangem/common/routing/DeepLinkRoute.kt | 4 + .../common/routing/deeplink/DeeplinkConst.kt | 3 + .../markets/PreselectedMarketsInterval.kt | 15 ++++ .../domain/markets/PreselectedMarketsOrder.kt | 17 ++++ .../markets/PreselectedTokenDetailsSection.kt | 13 +++ .../feed/entry/components/FeedEntryRoute.kt | 11 ++- .../entry/deeplink/MarketsDeepLinkHandler.kt | 2 +- .../MarketsTokenExchangesDeepLinkHandler.kt | 10 +++ .../components/DefaultFeedEntryComponent.kt | 32 ++++++- .../DefaultMarketsTokenDetailsComponent.kt | 4 + .../list/DefaultMarketsTokenListComponent.kt | 2 + .../deeplink/DefaultMarketsDeepLinkHandler.kt | 15 +++- ...efaultMarketsTokenDetailDeepLinkHandler.kt | 14 ++- ...ultMarketsTokenExchangesDeepLinkHandler.kt | 88 +++++++++++++++++++ .../feed/deeplink/di/MarketsDeepLinkModule.kt | 8 ++ .../details/MarketsTokenDetailsModel.kt | 31 +++++++ .../model/market/list/MarketsListModel.kt | 1 + .../statemanager/MarketsListUMStateManager.kt | 3 +- .../detailed/MarketsTokenDetailsContent.kt | 7 ++ .../components/TokenMarketDetailsBody.kt | 11 ++- .../detailed/state/MarketsTokenDetailsUM.kt | 8 +- 26 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsInterval.kt create mode 100644 domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsOrder.kt create mode 100644 domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedTokenDetailsSection.kt create mode 100644 features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsTokenExchangesDeepLinkHandler.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenExchangesDeepLinkHandler.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2ef3c24014..95ce7f4c75 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -232,6 +232,17 @@ android:scheme="tangem" /> + + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index afff976cd4..588ae2ef38 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -195,6 +195,9 @@ internal class ChildFactory @Inject constructor( source = params.source, ) }, + preselectedSection = route.preselectedSection, + shouldOpenExchanges = route.shouldOpenExchanges, + exchangesCount = route.exchangesCount, ), componentFactory = feedEntryComponentFactory, ) @@ -458,7 +461,10 @@ internal class ChildFactory @Inject constructor( is AppRoute.Markets -> { createComponentChild( context = context, - params = FeedEntryRoute.MarketTokenList, + params = FeedEntryRoute.MarketTokenList( + preselectedOrder = route.preselectedOrder, + preselectedInterval = route.preselectedInterval, + ), componentFactory = feedEntryComponentFactory, ) } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index fb1acbcd80..492d1d23ae 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -8,6 +8,7 @@ import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler @@ -51,6 +52,7 @@ internal class DeepLinkFactory @Inject constructor( private val swapDeepLink: SwapDeepLinkHandler.Factory, private val promoDeepLink: PromoDeeplinkHandler.Factory, private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, + private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory, private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -148,8 +150,9 @@ internal class DeepLinkFactory @Inject constructor( isFromOnNewIntent = isFromOnNewIntent, ) DeepLinkRoute.Staking.host -> stakingDeepLink.create(coroutineScope, queryParams) - DeepLinkRoute.Markets.host -> marketsDeepLink.create() + DeepLinkRoute.Markets.host -> marketsDeepLink.create(queryParams) DeepLinkRoute.MarketTokenDetail.host -> marketsTokenDetailDeepLink.create(coroutineScope, queryParams) + DeepLinkRoute.TokenExchanges.host -> marketsTokenExchangesDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.Buy.host -> buyDeepLink.create() DeepLinkRoute.Sell.host -> sellDeepLink.create() DeepLinkRoute.Swap.host -> swapDeepLink.create() diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index 0cb5e4db8f..43ba7a2555 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -6,6 +6,7 @@ import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler @@ -55,7 +56,7 @@ class DeepLinkFactoryTest { every { create(any(), any()) } returns mockk() } private val marketsDeepLinkFactory = mockk(relaxed = true) { - every { create() } returns mockk() + every { create(any()) } returns mockk() } private val marketsTokenDetailDeepLinkFactory = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() @@ -86,6 +87,11 @@ class DeepLinkFactoryTest { every { create(any(), any()) } returns mockk() } + private val marketsTokenExchangesDeepLinkFactory = + mockk(relaxed = true) { + every { create(any(), any()) } returns mockk() + } + private val mockedUri = mockk(relaxed = true) private val isFromOnNewIntent: Boolean = false @@ -103,6 +109,7 @@ class DeepLinkFactoryTest { stakingDeepLink = stakingDeepLinkFactory, marketsDeepLink = marketsDeepLinkFactory, marketsTokenDetailDeepLink = marketsTokenDetailDeepLinkFactory, + marketsTokenExchangesDeepLink = marketsTokenExchangesDeepLinkFactory, buyDeepLink = buyDeepLinkFactory, sellDeepLink = sellDeepLinkFactory, swapDeepLink = swapDeepLinkFactory, @@ -308,7 +315,7 @@ class DeepLinkFactoryTest { every { mockedUri.host } returns "markets" deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() - verify { marketsDeepLinkFactory.create() } + verify { marketsDeepLinkFactory.create(any()) } // Test Sell every { mockedUri.host } returns "sell" diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 6fba5a36d5..e78861b261 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -10,6 +10,9 @@ import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.core.decompose.navigation.Route import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.markets.PreselectedMarketsInterval +import com.tangem.domain.markets.PreselectedMarketsOrder +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId @@ -253,7 +256,10 @@ sealed class AppRoute(val path: String) : Route { ) : AppRoute(path = "/wallet_hardware_backup/${userWalletId.stringValue}") @Serializable - data object Markets : AppRoute(path = "/markets") + data class Markets( + val preselectedOrder: PreselectedMarketsOrder? = null, + val preselectedInterval: PreselectedMarketsInterval? = null, + ) : AppRoute(path = "/markets") @Serializable data class MarketsTokenDetails( @@ -261,6 +267,9 @@ sealed class AppRoute(val path: String) : Route { val appCurrency: AppCurrency, val shouldShowPortfolio: Boolean, val analyticsParams: AnalyticsParams? = null, + val preselectedSection: PreselectedTokenDetailsSection? = null, + val shouldOpenExchanges: Boolean = false, + val exchangesCount: Int? = null, ) : AppRoute(path = "/markets_token_details/${token.id}/$shouldShowPortfolio") { @Serializable diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index d57eb5e7f0..359839b059 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -60,6 +60,10 @@ sealed class DeepLinkRoute { override val host: String = "promo" } + data object TokenExchanges : DeepLinkRoute() { + override val host: String = "token_exchanges" + } + data object OnboardVisa : DeepLinkRoute() { override val host: String = "onboard-visa" } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index 630b68ded8..6fe8031620 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -15,4 +15,7 @@ object DeeplinkConst { const val REF_KEY = "ref" const val CAMPAIGN_KEY = "campaign" const val NAME_KEY = "name" + const val ORDER_KEY = "order" + const val INTERVAL_KEY = "interval" + const val SECTION_KEY = "section" } \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsInterval.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsInterval.kt new file mode 100644 index 0000000000..d87f2342c1 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsInterval.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.markets + +import kotlinx.serialization.Serializable + +@Serializable +enum class PreselectedMarketsInterval(val value: String) { + H24("24h"), + W1("1w"), + D30("30d"), + ; + + companion object { + fun parse(value: String?): PreselectedMarketsInterval? = entries.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsOrder.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsOrder.kt new file mode 100644 index 0000000000..bc2cb04640 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedMarketsOrder.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.markets + +import kotlinx.serialization.Serializable + +@Serializable +enum class PreselectedMarketsOrder(val value: String) { + Rating("rating"), + Trending("trending"), + Buyers("buyers"), + Gainers("gainers"), + Losers("losers"), + ; + + companion object { + fun parse(value: String?): PreselectedMarketsOrder? = entries.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedTokenDetailsSection.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedTokenDetailsSection.kt new file mode 100644 index 0000000000..008a3b086e --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/PreselectedTokenDetailsSection.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.markets + +import kotlinx.serialization.Serializable + +@Serializable +enum class PreselectedTokenDetailsSection(val value: String) { + News("news"), + ; + + companion object { + fun parse(value: String?): PreselectedTokenDetailsSection? = entries.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt index 7c9ffb7c0a..ab97474343 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt @@ -1,6 +1,9 @@ package com.tangem.features.feed.entry.components import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PreselectedMarketsInterval +import com.tangem.domain.markets.PreselectedMarketsOrder +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.TokenMarketParams import kotlinx.serialization.Serializable @@ -13,6 +16,9 @@ sealed interface FeedEntryRoute { val appCurrency: AppCurrency, val shouldShowPortfolio: Boolean, val analyticsParams: AnalyticsParams? = null, + val preselectedSection: PreselectedTokenDetailsSection? = null, + val shouldOpenExchanges: Boolean = false, + val exchangesCount: Int? = null, ) : FeedEntryRoute { @Serializable @@ -23,7 +29,10 @@ sealed interface FeedEntryRoute { } @Serializable - data object MarketTokenList : FeedEntryRoute + data class MarketTokenList( + val preselectedOrder: PreselectedMarketsOrder? = null, + val preselectedInterval: PreselectedMarketsInterval? = null, + ) : FeedEntryRoute @Serializable data class NewsDetail(val articleId: Int, val preselectedArticlesId: List) : FeedEntryRoute diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsDeepLinkHandler.kt index 5909a4876a..4cd535a841 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsDeepLinkHandler.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsDeepLinkHandler.kt @@ -3,6 +3,6 @@ package com.tangem.features.feed.entry.deeplink interface MarketsDeepLinkHandler { interface Factory { - fun create(): MarketsDeepLinkHandler + fun create(params: Map): MarketsDeepLinkHandler } } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsTokenExchangesDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsTokenExchangesDeepLinkHandler.kt new file mode 100644 index 0000000000..1ebdae4226 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/MarketsTokenExchangesDeepLinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.entry.deeplink + +import kotlinx.coroutines.CoroutineScope + +interface MarketsTokenExchangesDeepLinkHandler { + + interface Factory { + fun create(coroutineScope: CoroutineScope, params: Map): MarketsTokenExchangesDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 48d74bc9f4..74271e53a0 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -29,6 +29,9 @@ import com.tangem.features.feed.entry.components.FeedEntryComponent import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.feed.model.FeedEntryModel import com.tangem.features.feed.model.feed.FeedModelClickIntents +import com.tangem.domain.markets.PreselectedMarketsInterval +import com.tangem.domain.markets.PreselectedMarketsOrder +import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.EntryContent import dagger.assisted.Assisted @@ -87,6 +90,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( route = FeedEntryChildFactory.Child.TokenList( params = DefaultMarketsTokenListComponent.Params( preselectedSortType = sortBy ?: SortByTypeUM.Rating, + preselectedInterval = MarketsListUM.TrendInterval.H24, shouldAlwaysShowSearchBar = sortBy == null, ), ), @@ -231,11 +235,15 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( paginationConfig = null, ) }, + preselectedSection = entryRoute.preselectedSection, + shouldOpenExchanges = entryRoute.shouldOpenExchanges, + exchangesCount = entryRoute.exchangesCount, ), ) - FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( + is FeedEntryRoute.MarketTokenList -> FeedEntryChildFactory.Child.TokenList( DefaultMarketsTokenListComponent.Params( - preselectedSortType = SortByTypeUM.Rating, + preselectedSortType = mapOrderToSortType(entryRoute.preselectedOrder), + preselectedInterval = mapIntervalToTrendInterval(entryRoute.preselectedInterval), shouldAlwaysShowSearchBar = false, ), ) @@ -265,4 +273,24 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( } } +private fun mapOrderToSortType(order: PreselectedMarketsOrder?): SortByTypeUM { + return when (order) { + PreselectedMarketsOrder.Rating -> SortByTypeUM.Rating + PreselectedMarketsOrder.Trending -> SortByTypeUM.Trending + PreselectedMarketsOrder.Buyers -> SortByTypeUM.ExperiencedBuyers + PreselectedMarketsOrder.Gainers -> SortByTypeUM.TopGainers + PreselectedMarketsOrder.Losers -> SortByTypeUM.TopLosers + null -> SortByTypeUM.Rating + } +} + +private fun mapIntervalToTrendInterval(interval: PreselectedMarketsInterval?): MarketsListUM.TrendInterval { + return when (interval) { + PreselectedMarketsInterval.H24 -> MarketsListUM.TrendInterval.H24 + PreselectedMarketsInterval.W1 -> MarketsListUM.TrendInterval.D7 + PreselectedMarketsInterval.D30 -> MarketsListUM.TrendInterval.M1 + null -> MarketsListUM.TrendInterval.H24 + } +} + internal interface FeedEntryClickIntents : FeedModelClickIntents \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index a9c1b09117..c4ac5551aa 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.res.vectorResource import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child @@ -228,6 +229,9 @@ internal class DefaultMarketsTokenDetailsComponent( val analyticsParams: AnalyticsParams?, val onBackClicked: () -> Unit, val onArticleClick: (articleId: Int, preselectedArticlesId: List) -> Unit, + val preselectedSection: PreselectedTokenDetailsSection? = null, + val shouldOpenExchanges: Boolean = false, + val exchangesCount: Int? = null, ) @Serializable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt index 2d69602f84..29e359843b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt @@ -29,6 +29,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.model.market.list.MarketsListModel +import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.market.list.MarketsList @@ -127,6 +128,7 @@ internal class DefaultMarketsTokenListComponent( @Serializable data class Params( val preselectedSortType: SortByTypeUM, + val preselectedInterval: MarketsListUM.TrendInterval, val shouldAlwaysShowSearchBar: Boolean, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsDeepLinkHandler.kt index 2e81775f75..b2d72c2863 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsDeepLinkHandler.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsDeepLinkHandler.kt @@ -2,20 +2,31 @@ package com.tangem.features.feed.deeplink import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.INTERVAL_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.ORDER_KEY +import com.tangem.domain.markets.PreselectedMarketsInterval +import com.tangem.domain.markets.PreselectedMarketsOrder import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler +import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject internal class DefaultMarketsDeepLinkHandler @AssistedInject constructor( + @Assisted private val queryParams: Map, appRouter: AppRouter, ) : MarketsDeepLinkHandler { init { - appRouter.push(AppRoute.Markets) + appRouter.push( + AppRoute.Markets( + preselectedOrder = PreselectedMarketsOrder.parse(queryParams[ORDER_KEY]), + preselectedInterval = PreselectedMarketsInterval.parse(queryParams[INTERVAL_KEY]), + ), + ) } @AssistedFactory interface Factory : MarketsDeepLinkHandler.Factory { - override fun create(): DefaultMarketsDeepLinkHandler + override fun create(params: Map): DefaultMarketsDeepLinkHandler } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt index 89c501508c..89cc9b9877 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenDetailDeepLinkHandler.kt @@ -2,7 +2,9 @@ package com.tangem.features.feed.deeplink import arrow.core.getOrElse import com.tangem.common.routing.AppRoute +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.SECTION_KEY import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -11,12 +13,12 @@ import com.tangem.domain.markets.GetTokenMarketInfoUseCase import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject constructor( @Assisted private val scope: CoroutineScope, @@ -32,8 +34,15 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc private fun handleDeepLink() { val tokenId = queryParams[TOKEN_ID_KEY] + val section = PreselectedTokenDetailsSection.parse(queryParams[SECTION_KEY]) - val rawTokenId = CryptoCurrency.RawID(tokenId.orEmpty()) + if (tokenId.isNullOrEmpty()) { + TangemLogger.e("Markets token details deeplink does not contain token_id") + appRouter.push(AppRoute.Markets()) + return + } + + val rawTokenId = CryptoCurrency.RawID(tokenId) scope.launch { val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { @@ -65,6 +74,7 @@ internal class DefaultMarketsTokenDetailDeepLinkHandler @AssistedInject construc appCurrency = appCurrency, shouldShowPortfolio = true, analyticsParams = null, + preselectedSection = section, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenExchangesDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenExchangesDeepLinkHandler.kt new file mode 100644 index 0000000000..4fbd77c4ba --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultMarketsTokenExchangesDeepLinkHandler.kt @@ -0,0 +1,88 @@ +package com.tangem.features.feed.deeplink + +import arrow.core.getOrElse +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY +import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.GetTokenMarketInfoUseCase +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class DefaultMarketsTokenExchangesDeepLinkHandler @AssistedInject constructor( + @Assisted private val scope: CoroutineScope, + @Assisted private val queryParams: Map, + private val appRouter: AppRouter, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, +) : MarketsTokenExchangesDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + val tokenId = queryParams[TOKEN_ID_KEY] + + if (tokenId.isNullOrEmpty()) { + TangemLogger.e("Markets token exchanges deeplink does not contain token_id") + appRouter.push(AppRoute.Markets()) + return + } + + val rawTokenId = CryptoCurrency.RawID(tokenId) + + scope.launch { + val appCurrency = getSelectedAppCurrencyUseCase.invokeSync().getOrElse { + AppCurrency.Default + } + val tokenInfo = getTokenMarketInfoUseCase( + appCurrency = appCurrency, + tokenId = rawTokenId, + tokenSymbol = "", + ).getOrElse { + TangemLogger.e("Failed to get market token info for exchanges deeplink") + appRouter.push(AppRoute.Markets()) + return@launch + } + + appRouter.push( + AppRoute.MarketsTokenDetails( + token = TokenMarketParams( + id = rawTokenId, + name = tokenInfo.name, + symbol = tokenInfo.symbol, + tokenQuotes = TokenMarketParams.Quotes( + currentPrice = tokenInfo.quotes.currentPrice, + h24Percent = tokenInfo.quotes.h24ChangePercent, + weekPercent = tokenInfo.quotes.weekChangePercent, + monthPercent = tokenInfo.quotes.monthChangePercent, + ), + imageUrl = getTokenIconUrlFromDefaultHost(rawTokenId), + ), + appCurrency = appCurrency, + shouldShowPortfolio = true, + shouldOpenExchanges = true, + exchangesCount = tokenInfo.exchangesAmount, + ), + ) + } + } + + @AssistedFactory + interface Factory : MarketsTokenExchangesDeepLinkHandler.Factory { + override fun create( + coroutineScope: CoroutineScope, + queryParams: Map, + ): DefaultMarketsTokenExchangesDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/MarketsDeepLinkModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/MarketsDeepLinkModule.kt index def0bf84fb..1e1e90cc24 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/MarketsDeepLinkModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/MarketsDeepLinkModule.kt @@ -2,8 +2,10 @@ package com.tangem.features.feed.deeplink.di import com.tangem.features.feed.deeplink.DefaultMarketsDeepLinkHandler import com.tangem.features.feed.deeplink.DefaultMarketsTokenDetailDeepLinkHandler +import com.tangem.features.feed.deeplink.DefaultMarketsTokenExchangesDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -23,4 +25,10 @@ internal interface MarketsDeepLinkModule { fun bindMarketsTokenDetailDeepLinkHandlerFactory( impl: DefaultMarketsTokenDetailDeepLinkHandler.Factory, ): MarketsTokenDetailDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindMarketsTokenExchangesDeepLinkHandlerFactory( + impl: DefaultMarketsTokenExchangesDeepLinkHandler.Factory, + ): MarketsTokenExchangesDeepLinkHandler.Factory } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index 05f213308c..e412504e4d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -5,6 +5,7 @@ import arrow.core.Either import arrow.core.getOrElse import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.TangemSiteShareUrlBuilder +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.MarketChartDataProducer import com.tangem.common.ui.charts.state.sorted @@ -21,6 +22,7 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.format.bigdecimal.fiat @@ -95,6 +97,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val quotesJob = JobHolder() private var userCountry: UserCountry? = null + private var isScrollToSectionHandled = false private val params = paramsContainer.require() private val analyticsEventBuilder = MarketDetailsAnalyticsEvent.EventBuilder(token = params.token) @@ -297,6 +300,15 @@ internal class MarketsTokenDetailsModel @Inject constructor( initialLoad() loadRelatedNews() + + if (params.shouldOpenExchanges) { + modelScope.launch { + val exchangesCount = params.exchangesCount + ?: currentTokenInfo.value?.exchangesAmount + ?: 0 + onListedOnClick(exchangesCount) + } + } } private fun initialLoad() { @@ -519,6 +531,14 @@ internal class MarketsTokenDetailsModel @Inject constructor( description = descriptionConverter.convert(newInfo), infoBlocks = infoConverter.convert(newInfo), ), + scrollToSection = if (!isScrollToSectionHandled) { + mapSectionToKey(params.preselectedSection)?.let { key -> + isScrollToSectionHandled = true + triggeredEvent(data = key, onConsume = ::consumeScrollToSection) + } ?: consumedEvent() + } else { + marketsTokenDetailsUM.scrollToSection + }, ) } @@ -755,6 +775,17 @@ internal class MarketsTokenDetailsModel @Inject constructor( ) } + private fun mapSectionToKey(section: PreselectedTokenDetailsSection?): String? { + return when (section) { + PreselectedTokenDetailsSection.News -> MarketsTokenDetailsUM.RelatedNews.SECTION_KEY + null -> null + } + } + + private fun consumeScrollToSection() { + state.update { it.copy(scrollToSection = consumedEvent()) } + } + private companion object { const val QUOTES_UPDATE_INTERVAL_MILLIS = 60000L const val RELATED_NEWS_LIMIT = 10 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index 11a933c2f1..79c0d6a2db 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -67,6 +67,7 @@ internal class MarketsListModel @Inject constructor( onShowTokensUnder100kClicked = { analyticsEventHandler.send(MarketsListAnalyticsEvent.ShowTokens()) }, shouldAlwaysShowSearchBar = Provider { modelParams.params.shouldAlwaysShowSearchBar }, preselectedSortType = Provider { modelParams.params.preselectedSortType }, + preselectedInterval = Provider { modelParams.params.preselectedInterval }, onBackClick = modelParams.clickIntents.onBackClicked, analyticsEventHandler = analyticsEventHandler, onSearchBarClick = modelParams.clickIntents.onSearchClicked, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt index fe1b3b15a1..473c06b89b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListUMStateManager.kt @@ -27,6 +27,7 @@ internal class MarketsListUMStateManager( private val shouldAlwaysShowSearchBar: Provider, private val currentVisibleIds: Provider>, private val preselectedSortType: Provider, + private val preselectedInterval: Provider, private val onLoadMoreUiItems: () -> Unit, private val visibleItemsChanged: (itemsKeys: List) -> Unit, private val onRetryButtonClicked: () -> Unit, @@ -229,7 +230,7 @@ internal class MarketsListUMStateManager( shouldAlwaysShowSearchBar = shouldAlwaysShowSearchBar(), ), selectedSortBy = preselectedSortType(), - selectedInterval = MarketsListUM.TrendInterval.H24, + selectedInterval = preselectedInterval(), onIntervalClick = { selectedInterval = it }, onSortByButtonClick = { isSortByBottomSheetShown = true }, sortByBottomSheet = TangemBottomSheetConfig( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index 195e45cf2f..79330be97f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -106,6 +106,13 @@ private fun Content( lazyListState = lazyListState, onShouldShowPriceSubtitleChange = state.onShouldShowPriceSubtitleChange, ) + EventEffect(state.scrollToSection) { targetKey -> + val targetIndex = lazyListState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == targetKey }?.index + if (targetIndex != null) { + lazyListState.animateScrollToItem(targetIndex) + } + } var bottomSpacing by remember { mutableStateOf(0.dp) } Box( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index de92450ef8..b1d372de32 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -76,6 +76,8 @@ private fun LazyListScope.tokenMarketDetailsBodyV1( if (relatedNews.articles.isNotEmpty()) { relatedNews(relatedNews) + } else { + sectionStub(RelatedNews.SECTION_KEY) } aboutCoinHeader() @@ -91,6 +93,11 @@ private fun LazyListScope.tokenMarketDetailsBodyV1( } } +// Empty item with a key so that deeplink scroll-to-section can target it before the real content is composed +private fun LazyListScope.sectionStub(key: String) { + item(key) { } +} + @Suppress("CanBeNonNullable") private fun LazyListScope.tokenMarketDetailsBodyV2(state: MarketsTokenDetailsUM.Body, relatedNews: RelatedNews) { when (state) { @@ -244,6 +251,8 @@ internal fun LazyListScope.infoBlocksListV2(state: MarketsTokenDetailsUM.Informa if (relatedNews.articles.isNotEmpty()) { relatedNews(relatedNews) + } else { + sectionStub(RelatedNews.SECTION_KEY) } if (state.securityScore != null) { @@ -324,7 +333,7 @@ private fun LazyListScope.loadingInfoBlocksV2() { } private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { - item("related-news") { + item(RelatedNews.SECTION_KEY) { Column( modifier = Modifier .fillMaxWidth() diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt index 2cd7078d51..cb53a7dd7e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/state/MarketsTokenDetailsUM.kt @@ -5,6 +5,7 @@ import com.tangem.common.ui.charts.state.MarketChartDataProducer import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.event.consumedEvent import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.markets.PriceChangeInterval import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM @@ -30,6 +31,7 @@ internal data class MarketsTokenDetailsUM( val onShouldShowPriceSubtitleChange: (Boolean) -> Unit, val relatedNews: RelatedNews, val onShareClick: () -> Unit, + val scrollToSection: StateEvent = consumedEvent(), ) { data class ChartState( @@ -80,5 +82,9 @@ internal data class MarketsTokenDetailsUM( val onArticledClicked: (id: Int) -> Unit, val onFirstVisible: () -> Unit, val onScroll: () -> Unit, - ) + ) { + companion object { + const val SECTION_KEY = "related-news" + } + } } \ No newline at end of file From ce5f535ac57643cb06091959d7a6090ad562092e Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 9 Apr 2026 17:43:43 +0300 Subject: [PATCH 045/206] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 2 + .../components/currency/icon/CurrencyIcon.kt | 3 +- .../tokendetails/model/TokenDetailsModel.kt | 46 +- .../state/TokenDetailsBalanceBlockUM.kt | 39 +- .../state/TokenDetailsStateController.kt | 22 +- ...InitializeWithCryptoCurrencyTransformer.kt | 21 +- .../SetBalanceLoadingTransformer.kt | 23 + .../transformer/SetBalanceTransformer.kt | 131 +++++ .../ToggleBalanceTypeTransformer.kt | 31 ++ .../tokendetails/ui/TokenDetailsScreen.kt | 25 +- .../ui/components/TokenDetailsBalanceBlock.kt | 239 +++++++++ ...ializeWithCryptoCurrencyTransformerTest.kt | 10 +- .../SetBalanceLoadingTransformerTest.kt | 143 ++++++ .../transformer/SetBalanceTransformerTest.kt | 481 ++++++++++++++++++ .../ToggleBalanceTypeTransformerTest.kt | 203 ++++++++ 15 files changed, 1385 insertions(+), 34 deletions(-) create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformer.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformer.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 586600272c..93d7812b80 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1707,6 +1707,8 @@ Selling %s is not supported by current providers, but we are working to add more options. Staking %s is not supported by current providers, but we are working to add more options. Approval has been revoked. Your funds remain in Yield mode. To perform actions, please go to Yield mode and grant permission again. + Available balance + Total balance Generate XPUB Hide You are about to hide this token from the main screen. You can add it back anytime through the manage tokens page. diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt index f5cb062474..c0e1f4704f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIcon.kt @@ -42,10 +42,11 @@ fun CurrencyIcon( withFixedSize: Boolean = true, iconSize: Dp = 36.dp, ) { + val outerSize = iconSize + 4.dp Box( modifier = modifier .conditional(withFixedSize) { - size(size = 40.dp) + size(size = outerSize) }, ) { val iconModifier = Modifier diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index bb946cf2a8..2a3f0c895f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -32,6 +32,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.common.ui.tokens.getUnavailabilityReasonText +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference @@ -104,7 +105,10 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.TokenDetailsExpressStatusFactory import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceLoadingTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R @@ -176,6 +180,7 @@ internal class TokenDetailsModel @Inject constructor( private val getWalletIconUseCase: GetWalletIconUseCase, private val walletIconUMConverter: WalletIconUMConverter, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val designFeatureToggles: DesignFeatureToggles, private val redesignStateController: TokenDetailsStateController, ) : Model(), TokenDetailsClickIntents, @@ -197,6 +202,7 @@ internal class TokenDetailsModel @Inject constructor( private val stakingJobHolder = JobHolder() private val yieldSupplyBalanceJobHolder = JobHolder() private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() + private val redesignBalanceJobHolder = JobHolder() private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null private var account: Account.CryptoPortfolio? = null @@ -278,10 +284,8 @@ internal class TokenDetailsModel @Inject constructor( } init { - initRedesignState() + initRedesign() updateTopBarMenu() - updateRedesignTopBarMenu() - observeRedesignTopBarTitle() initButtons() updateContent() handleBalanceHiding() @@ -893,6 +897,11 @@ internal class TokenDetailsModel @Inject constructor( override fun onRefreshSwipe(isRefreshing: Boolean) { uiState.value = stateFactory.getRefreshingState() + redesignStateController.update( + SetBalanceLoadingTransformer( + currencyIconState = redesignStateController.value.balanceBlockUM.currencyIconState, + ), + ) modelScope.launch(dispatchers.main) { listOf( @@ -1368,6 +1377,29 @@ internal class TokenDetailsModel @Inject constructor( // endregion Clore migration + private fun observeRedesignBalance() { + getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .map { it.status } + .distinctUntilChanged() + .combine(selectedAppCurrencyFlow) { status, appCurrency -> status to appCurrency } + .onEach { (status, appCurrency) -> + redesignStateController.update( + SetBalanceTransformer( + status = status, + appCurrency = appCurrency, + onToggleBalanceType = ::toggleRedesignBalanceType, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + .saveIn(redesignBalanceJobHolder) + } + + private fun toggleRedesignBalanceType() { + redesignStateController.update(ToggleBalanceTypeTransformer()) + } + private fun updateRedesignTopBarMenu() { modelScope.launch(dispatchers.main) { val hasDerivations = networkHasDerivationUseCase( @@ -1389,6 +1421,14 @@ internal class TokenDetailsModel @Inject constructor( } } + private fun initRedesign() { + if (!designFeatureToggles.isRedesignEnabled) return + initRedesignState() + observeRedesignBalance() + updateRedesignTopBarMenu() + observeRedesignTopBarTitle() + } + private fun initRedesignState() { redesignStateController.update( InitializeWithCryptoCurrencyTransformer( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt index aab3bd7c14..a4cfef3e52 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsBalanceBlockUM.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tokendetails.impl.R import kotlinx.collections.immutable.ImmutableList @Immutable @@ -23,10 +25,25 @@ internal sealed class TokenDetailsBalanceBlockUM { override val actionButtons: ImmutableList, override val tokenBalanceTypeUM: TokenBalanceTypeUM, override val currencyIconState: CurrencyIconState, - val displayCryptoBalance: TextReference, - val displayFiatBalance: TextReference, + val displayCryptoBalanceAll: TextReference, + val displayFiatBalanceAll: TextReference, + val displayCryptoBalanceAvailable: TextReference?, + val displayFiatBalanceAvailable: TextReference?, val isBalanceFlickering: Boolean, - ) : TokenDetailsBalanceBlockUM() + ) : TokenDetailsBalanceBlockUM() { + + val displayCryptoBalance: TextReference + get() = when (tokenBalanceTypeUM.type) { + TokenBalanceTypeUM.Type.ALL -> displayCryptoBalanceAll + TokenBalanceTypeUM.Type.AVAILABLE -> displayCryptoBalanceAvailable ?: displayCryptoBalanceAll + } + + val displayFiatBalance: TextReference + get() = when (tokenBalanceTypeUM.type) { + TokenBalanceTypeUM.Type.ALL -> displayFiatBalanceAll + TokenBalanceTypeUM.Type.AVAILABLE -> displayFiatBalanceAvailable ?: displayFiatBalanceAll + } + } data class Error( override val actionButtons: ImmutableList, @@ -34,11 +51,11 @@ internal sealed class TokenDetailsBalanceBlockUM { override val currencyIconState: CurrencyIconState, ) : TokenDetailsBalanceBlockUM() - fun copyActionButtons(buttons: ImmutableList): TokenDetailsBalanceBlockUM { + fun copyCurrencyIconState(iconState: CurrencyIconState): TokenDetailsBalanceBlockUM { return when (this) { - is Content -> this.copy(actionButtons = buttons) - is Error -> this.copy(actionButtons = buttons) - is Loading -> this.copy(actionButtons = buttons) + is Content -> this.copy(currencyIconState = iconState) + is Error -> this.copy(currencyIconState = iconState) + is Loading -> this.copy(currencyIconState = iconState) } } } @@ -54,11 +71,11 @@ internal sealed class TokenBalanceTypeUM { data class Multiple( override val type: Type, val availableTypes: ImmutableList, - val onSelect: (Type) -> Unit, + val onSelect: () -> Unit, ) : TokenBalanceTypeUM() - enum class Type { - ALL, - AVAILABLE, + enum class Type(val text: TextReference) { + ALL(resourceReference(R.string.token_details_balance_total)), + AVAILABLE(resourceReference(R.string.token_details_balance_available)), } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt index 41e505d325..7fcf3fadd1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt @@ -4,7 +4,12 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tokendetails.impl.R import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow @@ -37,7 +42,22 @@ internal class TokenDetailsStateController @Inject constructor() { menuItems = persistentListOf(), ), balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( - actionButtons = persistentListOf(), + actionButtons = persistentListOf( + TangemButtonUM( + text = resourceReference(R.string.tangempay_card_details_add_funds), + tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_down_24), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + TangemButtonUM( + text = resourceReference(R.string.common_transfer), + tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_up_24), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + ), tokenBalanceTypeUM = TokenBalanceTypeUM.Single, currencyIconState = CurrencyIconState.Loading, ), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt index 64f054639c..3ecf879fe4 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt @@ -1,5 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency @@ -12,12 +13,16 @@ internal class InitializeWithCryptoCurrencyTransformer( private val onBackClick: () -> Unit, ) : Transformer { - override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy( - topAppBarUM = prevState.topAppBarUM.copy( - titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = cryptoCurrency.name), - subtitle = stringReference(cryptoCurrency.symbol), - onBackClick = onBackClick, - ), - marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol), - ) + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val iconState = CryptoCurrencyToIconStateConverter().convert(cryptoCurrency) + return prevState.copy( + topAppBarUM = prevState.topAppBarUM.copy( + titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = cryptoCurrency.name), + subtitle = stringReference(cryptoCurrency.symbol), + onBackClick = onBackClick, + ), + balanceBlockUM = prevState.balanceBlockUM.copyCurrencyIconState(iconState), + marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol), + ) + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformer.kt new file mode 100644 index 0000000000..5b4852a7e3 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformer.kt @@ -0,0 +1,23 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class SetBalanceLoadingTransformer( + private val currencyIconState: CurrencyIconState, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val prevBalance = prevState.balanceBlockUM + return prevState.copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = prevBalance.actionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = currencyIconState, + ), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt new file mode 100644 index 0000000000..28da2953eb --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformer.kt @@ -0,0 +1,131 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import androidx.compose.ui.text.SpanStyle +import com.tangem.common.getTotalWithRewardsStakingBalance +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.defaultAmount +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.StringsSigns.DASH_SIGN +import com.tangem.utils.isNullOrZero +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import java.math.BigDecimal + +/** + * Maps [CryptoCurrencyStatus] into [TokenDetailsBalanceBlockUM] and sets it on the state. + * + * Produces [TokenDetailsBalanceBlockUM.Content] for loaded states, + * [TokenDetailsBalanceBlockUM.Loading] for loading, + * [TokenDetailsBalanceBlockUM.Error] for unreachable/no-amount/missed-derivation. + */ +internal class SetBalanceTransformer( + private val status: CryptoCurrencyStatus, + private val appCurrency: AppCurrency, + private val onToggleBalanceType: () -> Unit, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val prev = prevState.balanceBlockUM + val balanceBlockUM = when (status.value) { + is CryptoCurrencyStatus.Loading -> TokenDetailsBalanceBlockUM.Loading( + actionButtons = prev.actionButtons, + tokenBalanceTypeUM = prev.tokenBalanceTypeUM, + currencyIconState = prev.currencyIconState, + ) + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + is CryptoCurrencyStatus.Custom, + -> buildLoadedContent(prev) + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> TokenDetailsBalanceBlockUM.Error( + actionButtons = prev.actionButtons, + tokenBalanceTypeUM = prev.tokenBalanceTypeUM, + currencyIconState = prev.currencyIconState, + ) + } + return prevState.copy(balanceBlockUM = balanceBlockUM) + } + + private fun buildLoadedContent(prev: TokenDetailsBalanceBlockUM): TokenDetailsBalanceBlockUM.Content { + val stakingCryptoAmount = + (status.value.stakingBalance as? StakingBalance.Data)?.getTotalWithRewardsStakingBalance( + status.currency.network.rawId, + ) + val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) } + val hasStaking = !stakingCryptoAmount.isNullOrZero() + + val prevType = prev.tokenBalanceTypeUM + val tokenBalanceTypeUM = if (hasStaking) { + TokenBalanceTypeUM.Multiple( + type = (prevType as? TokenBalanceTypeUM.Multiple)?.type ?: TokenBalanceTypeUM.Type.ALL, + availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE), + onSelect = onToggleBalanceType, + ) + } else { + TokenBalanceTypeUM.Single + } + + return TokenDetailsBalanceBlockUM.Content( + actionButtons = prev.actionButtons, + currencyIconState = prev.currencyIconState, + tokenBalanceTypeUM = tokenBalanceTypeUM, + displayFiatBalanceAll = formatFiatStyled( + fiatAmount = computeTotal(status.value.fiatAmount, stakingFiatAmount), + ), + displayCryptoBalanceAll = formatCrypto( + amount = computeTotal(status.value.amount, stakingCryptoAmount), + ), + displayFiatBalanceAvailable = if (hasStaking) { + formatFiatStyled(fiatAmount = status.value.fiatAmount) + } else { + null + }, + displayCryptoBalanceAvailable = if (hasStaking) { + formatCrypto(amount = status.value.amount) + } else { + null + }, + isBalanceFlickering = status.value.sources.total == StatusSource.CACHE, + ) + } + + private fun computeTotal(base: BigDecimal?, staking: BigDecimal?): BigDecimal? { + if (base == null) return null + return if (staking != null) base + staking else base + } + + private fun formatFiatStyled(fiatAmount: BigDecimal?): TextReference { + if (fiatAmount == null) return stringReference(DASH_SIGN) + return fiatAmount.formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).defaultAmount( + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ) + } + } + + private fun formatCrypto(amount: BigDecimal?): TextReference { + if (amount == null) return stringReference(DASH_SIGN) + return stringReference( + amount.format { crypto(status.currency).defaultAmount() }, + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformer.kt new file mode 100644 index 0000000000..69302a474a --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformer.kt @@ -0,0 +1,31 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer + +/** + * Toggles the balance type between [TokenBalanceTypeUM.Type.ALL] and [TokenBalanceTypeUM.Type.AVAILABLE]. + * + * No-op if the current balance state is not [TokenDetailsBalanceBlockUM.Content] or its + * [TokenDetailsBalanceBlockUM.Content.tokenBalanceTypeUM] is not [TokenBalanceTypeUM.Multiple]. + */ +internal class ToggleBalanceTypeTransformer : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val content = prevState.balanceBlockUM as? TokenDetailsBalanceBlockUM.Content ?: return prevState + val multiple = content.tokenBalanceTypeUM as? TokenBalanceTypeUM.Multiple ?: return prevState + + val nextType = when (multiple.type) { + TokenBalanceTypeUM.Type.ALL -> TokenBalanceTypeUM.Type.AVAILABLE + TokenBalanceTypeUM.Type.AVAILABLE -> TokenBalanceTypeUM.Type.ALL + } + + return prevState.copy( + balanceBlockUM = content.copy( + tokenBalanceTypeUM = multiple.copy(type = nextType), + ), + ) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 6a1b96d329..25f0277b18 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -3,14 +3,20 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui import android.content.res.Configuration import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn + import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll + +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.common.ui.account.AccountIconUM @@ -33,6 +39,8 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeTint import kotlinx.collections.immutable.persistentListOf @@ -41,10 +49,10 @@ import kotlinx.collections.immutable.persistentListOf internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifier = Modifier) { val topAppBarUM = tokenDetailsUM.topAppBarUM - // TODO [REDACTED_TASK_KEY] Token Details Make Balance with actions - val balanceBlockHeight = 200.dp - val partialCollapsedHeight = 0.dp - val expandedHeight = balanceBlockHeight + partialCollapsedHeight + val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() } + val topBarHeight = 64.dp + val partialCollapsedHeight = topBarHeight + statusBarHeight + val expandedHeight = TokenDetailsBalanceBlockHeight + partialCollapsedHeight val behavior = rememberTangemExitUntilCollapsedScrollBehavior( expandedHeight = expandedHeight, @@ -62,11 +70,12 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi TangemCollapsingTopBar( state = behavior.state, collapsingPart = { - // [REDACTED_TASK_KEY] Token Details Make Balance with actions - Box( + TokenDetailsBalanceBlock( + balanceBlockUM = tokenDetailsUM.balanceBlockUM, modifier = Modifier .fillMaxWidth() - .height(balanceBlockHeight), + .statusBarsPadding() + .padding(top = topBarHeight), ) }, body = { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt new file mode 100644 index 0000000000..ee3f598efc --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -0,0 +1,239 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.button.action.ActionButtons +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.LocalRootBackgroundColor +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.features.tokendetails.impl.R +import kotlinx.collections.immutable.persistentListOf + +private val CurrencyIconSize: Dp = 70.dp +private val NetworkBadgeSize: Dp = 24.dp +internal val TokenDetailsBalanceBlockHeight: Dp = 404.dp + +@Composable +internal fun TokenDetailsBalanceBlock(balanceBlockUM: TokenDetailsBalanceBlockUM, modifier: Modifier = Modifier) { + val rootBackground by LocalRootBackgroundColor.current + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x10), + ) { + CurrencyIcon( + state = balanceBlockUM.currencyIconState, + shouldDisplayNetwork = true, + iconSize = CurrencyIconSize, + networkBadgeSize = NetworkBadgeSize, + networkBadgeBackground = rootBackground, + ) + SpacerH(TangemTheme.dimens2.x3) + when (balanceBlockUM) { + is TokenDetailsBalanceBlockUM.Content -> ContentBody(state = balanceBlockUM) + is TokenDetailsBalanceBlockUM.Loading -> LoadingBody() + is TokenDetailsBalanceBlockUM.Error -> ErrorBody() + } + SpacerH(TangemTheme.dimens2.x10) + ActionButtons(buttons = balanceBlockUM.actionButtons) + } +} + +@Composable +private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content) { + AnimatedContent( + targetState = state.tokenBalanceTypeUM.type, + label = "Token balance type", + ) { currentType -> + val tokenBalanceTypeUM = state.tokenBalanceTypeUM + when (tokenBalanceTypeUM) { + is TokenBalanceTypeUM.Multiple -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + modifier = Modifier.clickable(onClick = tokenBalanceTypeUM.onSelect), + ) { + Text( + text = currentType.text.resolveReference(), + style = TangemTheme.typography2.calloutSemibold15, + color = TangemTheme.colors2.text.neutral.secondary, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_sort_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.secondary, + modifier = Modifier.size(TangemTheme.dimens2.x4), + ) + } + TokenBalanceTypeUM.Single -> Text( + text = currentType.text.resolveReference(), + style = TangemTheme.typography2.calloutSemibold15, + color = TangemTheme.colors2.text.neutral.secondary, + ) + } + } + SpacerH(TangemTheme.dimens2.x2) + Text( + text = state.displayFiatBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.titleRegular44, + color = TangemTheme.colors2.text.neutral.primary, + ) + SpacerH(TangemTheme.dimens2.x2_5) + Text( + text = state.displayCryptoBalance.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.secondary, + ) +} + +@Composable +private fun LoadingBody() { + Text( + text = TokenBalanceTypeUM.Type.ALL.text.resolveReference(), + style = TangemTheme.typography2.calloutSemibold15, + color = TangemTheme.colors2.text.neutral.secondary, + ) + SpacerH(TangemTheme.dimens2.x2) + TextShimmer( + style = TangemTheme.typography2.titleRegular44, + text = "$1234567890", + radius = TangemTheme.dimens2.x6, + ) + SpacerH(TangemTheme.dimens2.x2) + TextShimmer( + style = TangemTheme.typography2.bodySemibold16, + text = "12345.67", + radius = TangemTheme.dimens2.x4, + ) +} + +@Composable +private fun ErrorBody() { + Text( + text = "—", + style = TangemTheme.typography2.titleRegular44, + color = TangemTheme.colors2.text.neutral.primary, + ) + SpacerH(TangemTheme.dimens2.x2_5) + Text( + text = "—", + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.secondary, + ) +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenDetailsBalanceBlock_Preview( + @PreviewParameter(PreviewProvider::class) params: TokenDetailsBalanceBlockUM, +) { + TangemThemePreviewRedesign { + TokenDetailsBalanceBlock( + balanceBlockUM = params, + modifier = Modifier.background(TangemTheme.colors2.surface.level2), + ) + } +} + +private class PreviewProvider : PreviewParameterProvider { + + private val previewActionButtons = persistentListOf( + TangemButtonUM( + text = stringReference("Add funds"), + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_arrow_down_24, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + TangemButtonUM( + text = stringReference("Transfer"), + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_arrow_up_24, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + onClick = { }, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + ) + + override val values: Sequence + get() = sequenceOf( + TokenDetailsBalanceBlockUM.Content( + actionButtons = previewActionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple( + type = TokenBalanceTypeUM.Type.ALL, + availableTypes = persistentListOf( + TokenBalanceTypeUM.Type.ALL, + TokenBalanceTypeUM.Type.AVAILABLE, + ), + onSelect = { }, + ), + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("0.0613884 BTC"), + displayFiatBalanceAll = stringReference("$12,380.94"), + displayCryptoBalanceAvailable = stringReference("0.05 BTC"), + displayFiatBalanceAvailable = stringReference("$10,000.00"), + isBalanceFlickering = false, + ), + TokenDetailsBalanceBlockUM.Content( + actionButtons = previewActionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("123.456 USDT"), + displayFiatBalanceAll = stringReference("$123.45"), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + ), + TokenDetailsBalanceBlockUM.Loading( + actionButtons = previewActionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + TokenDetailsBalanceBlockUM.Error( + actionButtons = previewActionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + ) +} +// endregion \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt index 43672cf4c9..8c0623c3e8 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt @@ -5,6 +5,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState @@ -99,7 +100,8 @@ class InitializeWithCryptoCurrencyTransformerTest { // THEN — only top bar title/subtitle/onBackClick and marketPriceBlockState are touched assertThat(result.topAppBarUM.menuItems).isEqualTo(state.topAppBarUM.menuItems) - assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM) + assertThat(result.balanceBlockUM.actionButtons).isEqualTo(state.balanceBlockUM.actionButtons) + assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(state.balanceBlockUM.tokenBalanceTypeUM) assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState) assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) @@ -113,7 +115,11 @@ class InitializeWithCryptoCurrencyTransformerTest { onBackClick = {}, menuItems = persistentListOf(), ), - balanceBlockUM = mockk(relaxed = true), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = mockk(relaxed = true), + ), marketPriceBlockState = mockk(relaxed = true), stakingBlocksState = null, pullToRefreshConfig = mockk(relaxed = true), diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt new file mode 100644 index 0000000000..1d7b67394f --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt @@ -0,0 +1,143 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class SetBalanceLoadingTransformerTest { + + private val currencyIconState: CurrencyIconState = mockk(relaxed = true) + + @Test + fun `GIVEN any state WHEN transform THEN balance block is Loading`() { + // GIVEN + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java) + } + + @Test + fun `GIVEN currency icon state WHEN transform THEN Loading block carries that icon state`() { + // GIVEN + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM.currencyIconState).isSameInstanceAs(currencyIconState) + } + + @Test + fun `GIVEN state with action buttons WHEN transform THEN action buttons are preserved`() { + // GIVEN + val buttons = persistentListOf( + TangemButtonUM( + text = stringReference("Test"), + onClick = {}, + isEnabled = true, + type = TangemButtonType.Secondary, + ), + ) + val state = initialState(actionButtons = buttons) + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.balanceBlockUM.actionButtons).isEqualTo(buttons) + } + + @Test + fun `GIVEN any state WHEN transform THEN balance type is Single`() { + // GIVEN + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single) + } + + @Test + fun `GIVEN Content balance block WHEN transform THEN switches to Loading`() { + // GIVEN + val contentState = initialState().copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Content( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("1.0 BTC"), + displayFiatBalanceAll = stringReference("$50,000"), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + ), + ) + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(contentState) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java) + } + + @Test + fun `GIVEN any state WHEN transform THEN unrelated fields are preserved`() { + // GIVEN + val state = initialState() + val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + private fun initialState( + actionButtons: ImmutableList = persistentListOf(), + ): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = actionButtons, + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt new file mode 100644 index 0000000000..27a1733679 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt @@ -0,0 +1,481 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.getTotalWithRewardsStakingBalance +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +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.staking.StakingBalance +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class SetBalanceTransformerTest { + + private val onToggleBalanceType: () -> Unit = mockk(relaxed = true) + private val appCurrency: AppCurrency = AppCurrency.Default + + private val network: Network = mockk(relaxed = true) { + every { rawId } returns "ethereum" + } + private val currency: CryptoCurrency = mockk(relaxed = true) { + every { this@mockk.network } returns this@SetBalanceTransformerTest.network + every { symbol } returns "ETH" + } + + @BeforeEach + fun setup() { + mockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance) + } + + @AfterEach + fun teardown() { + unmockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance) + } + + // region Status type → BalanceBlock type mapping + + @Test + fun `GIVEN Loading status WHEN transform THEN balance block is Loading`() { + // GIVEN + val status = createStatus(CryptoCurrencyStatus.Loading) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java) + } + + @Test + fun `GIVEN Loaded status WHEN transform THEN balance block is Content`() { + // GIVEN + val status = createStatus(loadedValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java) + } + + @Test + fun `GIVEN NoQuote status WHEN transform THEN balance block is Content`() { + // GIVEN + val status = createStatus(noQuoteValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java) + } + + @Test + fun `GIVEN NoAccount status WHEN transform THEN balance block is Content`() { + // GIVEN + val status = createStatus(noAccountValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java) + } + + @Test + fun `GIVEN Custom status WHEN transform THEN balance block is Content`() { + // GIVEN + val status = createStatus(customValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java) + } + + @Test + fun `GIVEN Unreachable status WHEN transform THEN balance block is Error`() { + // GIVEN + val status = createStatus( + CryptoCurrencyStatus.Unreachable(priceChange = null, fiatRate = null, networkAddress = null), + ) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java) + } + + @Test + fun `GIVEN NoAmount status WHEN transform THEN balance block is Error`() { + // GIVEN + val status = createStatus(CryptoCurrencyStatus.NoAmount(priceChange = null, fiatRate = null)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java) + } + + @Test + fun `GIVEN MissedDerivation status WHEN transform THEN balance block is Error`() { + // GIVEN + val status = createStatus(CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java) + } + + // endregion + + // region Action buttons & icon preservation + + @Test + fun `GIVEN any loaded status WHEN transform THEN action buttons are preserved`() { + // GIVEN + val status = createStatus(loadedValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM.actionButtons).isEqualTo(initialState().balanceBlockUM.actionButtons) + } + + @Test + fun `GIVEN any loaded status WHEN transform THEN currency icon state is preserved`() { + // GIVEN + val status = createStatus(loadedValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.balanceBlockUM.currencyIconState) + .isEqualTo(initialState().balanceBlockUM.currencyIconState) + } + + // endregion + + // region Staking / balance type + + @Test + fun `GIVEN loaded status without staking WHEN transform THEN balance type is Single`() { + // GIVEN + val status = createStatus(loadedValue(stakingBalance = null)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single) + } + + @Test + fun `GIVEN loaded status with staking WHEN transform THEN balance type is Multiple`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.tokenBalanceTypeUM).isInstanceOf(TokenBalanceTypeUM.Multiple::class.java) + } + + @Test + fun `GIVEN loaded status with staking WHEN transform THEN available balance types include ALL and AVAILABLE`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.availableTypes).containsExactly( + TokenBalanceTypeUM.Type.ALL, + TokenBalanceTypeUM.Type.AVAILABLE, + ) + } + + @Test + fun `GIVEN staking balance WHEN Multiple onSelect invoked THEN onToggleBalanceType is called`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + multiple.onSelect() + + // THEN + verify(exactly = 1) { onToggleBalanceType.invoke() } + } + + @Test + fun `GIVEN staking and previous Multiple type AVAILABLE WHEN transform THEN selected type is preserved`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + val prevContent = TokenDetailsBalanceBlockUM.Content( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple( + type = TokenBalanceTypeUM.Type.AVAILABLE, + availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE), + onSelect = {}, + ), + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference(""), + displayFiatBalanceAll = stringReference(""), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + ) + val state = initialState().copy(balanceBlockUM = prevContent) + + // WHEN + val result = transformer.transform(state) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE) + } + + // endregion + + // region Balance flickering + + @Test + fun `GIVEN CACHE source WHEN transform THEN isBalanceFlickering is true`() { + // GIVEN + val sources = CryptoCurrencyStatus.Sources( + networkSource = StatusSource.CACHE, + quoteSource = StatusSource.CACHE, + stakingBalanceSource = StatusSource.CACHE, + ) + val status = createStatus(loadedValue(sources = sources)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.isBalanceFlickering).isTrue() + } + + @Test + fun `GIVEN ACTUAL source WHEN transform THEN isBalanceFlickering is false`() { + // GIVEN + val sources = CryptoCurrencyStatus.Sources( + networkSource = StatusSource.ACTUAL, + quoteSource = StatusSource.ACTUAL, + stakingBalanceSource = StatusSource.ACTUAL, + ) + val status = createStatus(loadedValue(sources = sources)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.isBalanceFlickering).isFalse() + } + + // endregion + + // region No staking → available balances + + @Test + fun `GIVEN loaded without staking WHEN transform THEN available balances are null`() { + // GIVEN + val status = createStatus(loadedValue(stakingBalance = null)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.displayCryptoBalanceAvailable).isNull() + assertThat(content.displayFiatBalanceAvailable).isNull() + } + + @Test + fun `GIVEN staking balance WHEN transform THEN available balances are not null`() { + // GIVEN + val stakingBalance: StakingBalance.Data = mockk(relaxed = true) + every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5") + val status = createStatus(loadedValue(stakingBalance = stakingBalance)) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(content.displayCryptoBalanceAvailable).isNotNull() + assertThat(content.displayFiatBalanceAvailable).isNotNull() + } + + // endregion + + // region Unrelated fields preserved + + @Test + fun `GIVEN any status WHEN transform THEN unrelated fields are preserved`() { + // GIVEN + val state = initialState() + val status = createStatus(loadedValue()) + val transformer = createTransformer(status) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + // endregion + + private fun createTransformer(status: CryptoCurrencyStatus) = SetBalanceTransformer( + status = status, + appCurrency = appCurrency, + onToggleBalanceType = onToggleBalanceType, + ) + + private fun createStatus(value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus( + currency = currency, + value = value, + ) + + private fun loadedValue( + amount: BigDecimal = BigDecimal("10.5"), + fiatAmount: BigDecimal = BigDecimal("21000"), + fiatRate: BigDecimal = BigDecimal("2000"), + stakingBalance: StakingBalance? = null, + sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(), + ): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded( + amount = amount, + fiatAmount = fiatAmount, + fiatRate = fiatRate, + priceChange = BigDecimal("2.5"), + stakingBalance = stakingBalance, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = sources, + ) + + private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote( + amount = BigDecimal("5.0"), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + + private fun noAccountValue(): CryptoCurrencyStatus.NoAccount = CryptoCurrencyStatus.NoAccount( + amountToCreateAccount = BigDecimal("0.01"), + fiatAmount = BigDecimal.ZERO, + priceChange = null, + fiatRate = BigDecimal("2000"), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + + private fun customValue(): CryptoCurrencyStatus.Custom = CryptoCurrencyStatus.Custom( + amount = BigDecimal("100"), + fiatAmount = null, + fiatRate = null, + priceChange = null, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt new file mode 100644 index 0000000000..2a288fa164 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt @@ -0,0 +1,203 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +class ToggleBalanceTypeTransformerTest { + + private val transformer = ToggleBalanceTypeTransformer() + + // region Toggle logic + + @Test + fun `GIVEN Multiple with ALL WHEN transform THEN type switches to AVAILABLE`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL) + + // WHEN + val result = transformer.transform(state) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE) + } + + @Test + fun `GIVEN Multiple with AVAILABLE WHEN transform THEN type switches to ALL`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.AVAILABLE) + + // WHEN + val result = transformer.transform(state) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL) + } + + @Test + fun `GIVEN Multiple with ALL WHEN transform twice THEN type returns to ALL`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL) + + // WHEN + val result = transformer.transform(transformer.transform(state)) + + // THEN + val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content) + .tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple + assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL) + } + + // endregion + + // region No-op cases + + @Test + fun `GIVEN Loading balance block WHEN transform THEN state is unchanged`() { + // GIVEN + val state = initialState() + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result).isSameInstanceAs(state) + } + + @Test + fun `GIVEN Error balance block WHEN transform THEN state is unchanged`() { + // GIVEN + val state = initialState().copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Error( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + ) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result).isSameInstanceAs(state) + } + + @Test + fun `GIVEN Content with Single balance type WHEN transform THEN state is unchanged`() { + // GIVEN + val state = initialState().copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Content( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("1.0 ETH"), + displayFiatBalanceAll = stringReference("$2,000"), + displayCryptoBalanceAvailable = null, + displayFiatBalanceAvailable = null, + isBalanceFlickering = false, + ), + ) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result).isSameInstanceAs(state) + } + + // endregion + + // region Unrelated fields preserved + + @Test + fun `GIVEN any togglable state WHEN transform THEN unrelated fields are preserved`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + @Test + fun `GIVEN togglable state WHEN transform THEN balance content fields besides type are preserved`() { + // GIVEN + val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL) + val originalContent = state.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + + // WHEN + val result = transformer.transform(state) + + // THEN + val resultContent = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content + assertThat(resultContent.actionButtons).isEqualTo(originalContent.actionButtons) + assertThat(resultContent.currencyIconState).isEqualTo(originalContent.currencyIconState) + assertThat(resultContent.displayCryptoBalanceAll).isEqualTo(originalContent.displayCryptoBalanceAll) + assertThat(resultContent.displayFiatBalanceAll).isEqualTo(originalContent.displayFiatBalanceAll) + assertThat(resultContent.isBalanceFlickering).isEqualTo(originalContent.isBalanceFlickering) + } + + // endregion + + private fun stateWithContent(type: TokenBalanceTypeUM.Type): TokenDetailsUM { + return initialState().copy( + balanceBlockUM = TokenDetailsBalanceBlockUM.Content( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple( + type = type, + availableTypes = persistentListOf( + TokenBalanceTypeUM.Type.ALL, + TokenBalanceTypeUM.Type.AVAILABLE, + ), + onSelect = {}, + ), + currencyIconState = CurrencyIconState.Loading, + displayCryptoBalanceAll = stringReference("10.5 ETH"), + displayFiatBalanceAll = stringReference("$21,000"), + displayCryptoBalanceAvailable = stringReference("9.0 ETH"), + displayFiatBalanceAvailable = stringReference("$18,000"), + isBalanceFlickering = false, + ), + ) + } + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = ""), + subtitle = stringReference(""), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( + actionButtons = persistentListOf(), + tokenBalanceTypeUM = TokenBalanceTypeUM.Single, + currencyIconState = CurrencyIconState.Loading, + ), + marketPriceBlockState = mockk(relaxed = true), + stakingBlocksState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file From b04f28cf041c121f0f324a79984a0ee7fc76f054 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Apr 2026 15:07:44 +0200 Subject: [PATCH 046/206] Updated on 2026-08-14 --- .../com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt | 4 +++- .../features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt index 6adccd58a0..0e42de4752 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt @@ -32,6 +32,7 @@ import com.tangem.core.ui.format.bigdecimal.uncapped import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BaseAmountBlockTestTags +import com.tangem.utils.StringsSigns @Composable fun AmountBlockV2( @@ -39,6 +40,7 @@ fun AmountBlockV2( isClickDisabled: Boolean, isEditingDisabled: Boolean, modifier: Modifier = Modifier, + showApproximatePrefix: Boolean = false, onClick: (() -> Unit)? = null, extraContent: @Composable () -> Unit = {}, ) { @@ -71,7 +73,7 @@ fun AmountBlockV2( balance = amountState.availableBalanceCrypto, currencyTitle = currencyTitle, currencyIconState = amountState.tokenIconState, - firstAmount = firstAmount, + firstAmount = if (showApproximatePrefix) StringsSigns.TILDE_SIGN + firstAmount else firstAmount, secondAmount = secondAmount, isClickDisabled = isClickDisabled, isEditingDisabled = isEditingDisabled, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt index 6db639d48a..5c2914a574 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt @@ -37,6 +37,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact @@ -120,11 +121,13 @@ private fun ConstraintLayoutScope.SwapAmountBlock( end.linkTo(parent.end) }, ) + val isFloatRate = amountUM.swapRateType == ExpressRateType.Float AmountBlockV2( amountState = (amountUM.secondaryAmount.amountField as? AmountState.Data)?.copy( accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.send_with_swap_recipient_amount_title)), availableBalanceCrypto = TextReference.EMPTY, ) ?: amountUM.secondaryAmount.amountField, + showApproximatePrefix = isFloatRate, isClickDisabled = true, isEditingDisabled = false, modifier = Modifier.constrainAs(toAmountRef) { From ebe84a667e30aa4fc859d966a3682c85ad383f35 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Apr 2026 16:11:21 +0200 Subject: [PATCH 047/206] Updated on 2026-08-14 --- .../tap/domain/sdk/mocks/MockProvider.kt | 8 + .../mocks/content/FootballBlackMockContent.kt | 307 ++++++++++++++++++ .../content/FootballDarkGreenMockContent.kt | 307 ++++++++++++++++++ .../mocks/content/FrenchBlueMockContent.kt | 307 ++++++++++++++++++ .../mocks/content/FrenchWhiteMockContent.kt | 307 ++++++++++++++++++ .../content/MetaplanetDoubleMockContent.kt | 307 ++++++++++++++++++ .../mocks/content/MetaplanetMockContent.kt | 307 ++++++++++++++++++ .../content/RedPandaDoubleMockContent.kt | 307 ++++++++++++++++++ .../sdk/mocks/content/RedPandaMockContent.kt | 307 ++++++++++++++++++ 9 files changed, 2464 insertions(+) create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballBlackMockContent.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballDarkGreenMockContent.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchBlueMockContent.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchWhiteMockContent.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetDoubleMockContent.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetMockContent.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaDoubleMockContent.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaMockContent.kt diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt index cc16fe4302..59a5811c90 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt @@ -35,6 +35,14 @@ object MockProvider { "Backup Wallet" to BackupWalletMockContent, "Dev Wallet" to DevWalletMockContent, "Firmware 4.12" to Firmware412MockContent, + "French Blue (Triple)" to FrenchBlueMockContent, + "French White (Double)" to FrenchWhiteMockContent, + "Football Black (Double)" to FootballBlackMockContent, + "Football Dark Green (Triple)" to FootballDarkGreenMockContent, + "Metaplanet (Triple)" to MetaplanetMockContent, + "Metaplanet (Double)" to MetaplanetDoubleMockContent, + "Red Panda (Triple)" to RedPandaMockContent, + "Red Panda (Double)" to RedPandaDoubleMockContent, ) fun setEmulateError(error: TangemError? = null) { diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballBlackMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballBlackMockContent.kt new file mode 100644 index 0000000000..78baf5417f --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballBlackMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object FootballBlackMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF99009000000000", + batchId = "AF990090", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF99009000000000", + batchId = "AF990090", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF99009000000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballDarkGreenMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballDarkGreenMockContent.kt new file mode 100644 index 0000000000..fc45aedf6a --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FootballDarkGreenMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object FootballDarkGreenMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF99008900000000", + batchId = "AF990089", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF99008900000000", + batchId = "AF990089", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(2), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF99008900000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchBlueMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchBlueMockContent.kt new file mode 100644 index 0000000000..5b75f63266 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchBlueMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object FrenchBlueMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF99008400000000", + batchId = "AF990084", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF99008400000000", + batchId = "AF990084", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(2), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF99008400000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchWhiteMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchWhiteMockContent.kt new file mode 100644 index 0000000000..0ececd498e --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/FrenchWhiteMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object FrenchWhiteMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "AF99008500000000", + batchId = "AF990085", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "AF99008500000000", + batchId = "AF990085", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "AF99008500000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetDoubleMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetDoubleMockContent.kt new file mode 100644 index 0000000000..8680adaf4c --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetDoubleMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object MetaplanetDoubleMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "BB00004000000000", + batchId = "BB000040", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "BB00004000000000", + batchId = "BB000040", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "BB00004000000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetMockContent.kt new file mode 100644 index 0000000000..37bc112bb5 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/MetaplanetMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object MetaplanetMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "BB00004000000000", + batchId = "BB000040", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "BB00004000000000", + batchId = "BB000040", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(2), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "BB00004000000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaDoubleMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaDoubleMockContent.kt new file mode 100644 index 0000000000..89c0ddb763 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaDoubleMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object RedPandaDoubleMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "BB00003800000000", + batchId = "BB000038", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "BB00003800000000", + batchId = "BB000038", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(1), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "BB00003800000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaMockContent.kt new file mode 100644 index 0000000000..350319d3c8 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/RedPandaMockContent.kt @@ -0,0 +1,307 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.SuccessResponse +import com.tangem.common.card.* +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.operations.attestation.Attestation +import com.tangem.operations.backup.PrimaryCard +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.operations.wallet.CreateWalletResponse +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent +import java.util.Date + +object RedPandaMockContent : MockContent { + + private val primaryCard = PrimaryCard( + cardId = "BB00003800000000", + batchId = "BB000038", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + linkingKey = byteArrayOf( // + 2, 121, 98, 127, -70, 14, 5, -23, -76, 115, -30, -26, 111, 17, 110, 34, -100, -121, + -57, -123, 74, 3, -91, 56, -20, 56, 50, -40, -101, 96, 82, 70, -91, + ), + existingWalletsCount = 5, isHDWalletAllowed = true, + issuer = Card.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + manufacturer = Card.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1743759687), + signature = byteArrayOf(), + ), + walletCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + firmwareVersion = FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + isKeysImportAllowed = false, + certificate = null, + ) + + override val cardDto = CardDTO( + cardId = "BB00003800000000", + batchId = "BB000038", + cardPublicKey = byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + firmwareVersion = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = FirmwareVersion.FirmwareType.Release, + ), + manufacturer = CardDTO.Manufacturer( + name = "TANGEM", + manufactureDate = Date(1698094800000), + signature = byteArrayOf(51, -12, 14, -56, 7, -39, 5, 63, 59, 24, 102, 99, -126, 124, -127, -108, -118, 71, -19, -71, 4, -47, -121, -46, 49, -51, -31, 100, -56, -15, 96, -37, 25, 82, 94, -88, 48, 98, -105, -97, -40, 41, 27, 116, 65, 26, 78, -85, 66, -94, -92, 15, 50, -2, 7, -69, 41, 56, -75, 59, 86, 68, -38, -3), + ), + issuer = CardDTO.Issuer( + name = "TANGEM SDK", + publicKey = byteArrayOf(2, 95, 22, -67, 29, 46, -81, -28, 99, -26, 42, 51, 90, 9, -26, -78, -69, -53, -48, 68, 82, 82, 104, -123, -53, 103, -97, -60, -46, 122, -15, -67, 34), + ), + settings = CardDTO.Settings( + securityDelay = 15000, + maxWalletsCount = 20, + isSettingAccessCodeAllowed = true, + isSettingPasscodeAllowed = true, + isResettingUserCodesAllowed = false, + isLinkedTerminalEnabled = true, + isBackupAllowed = true, + supportedEncryptionModes = listOf(EncryptionMode.Strong, EncryptionMode.Fast, EncryptionMode.None), + isFilesAllowed = true, + isHDWalletAllowed = true, + isKeysImportAllowed = true, + ), + userSettings = CardDTO.UserSettings(isUserCodeRecoveryAllowed = true), + linkedTerminalStatus = CardDTO.LinkedTerminalStatus.None, + isAccessCodeSet = true, + isPasscodeSet = false, + supportedCurves = listOf( + EllipticCurve.Secp256k1, + EllipticCurve.Ed25519, + EllipticCurve.Bls12381G2Aug, + EllipticCurve.Secp256r1, + EllipticCurve.Ed25519Slip0010, + EllipticCurve.Bls12381G2, + EllipticCurve.Bls12381G2Pop, + EllipticCurve.Bip0340, + ), + wallets = listOf( + CardDTO.Wallet( + publicKey = byteArrayOf(3, 17, 59, -102, -56, 66, 10, 36, 97, -106, -92, -117, -105, -88, -2, -3, -95, -75, -54, -2, 57, 27, -90, -19, 82, 103, -12, -52, -126, -105, -120, -55, -2), + chainCode = byteArrayOf(-34, -28, -28, -12, 80, -109, -90, -31, -74, 36, -78, -21, 39, -125, -39, -91, 63, 40, -26, 3, 66, 27, -62, -55, -97, 52, -65, -11, 26, -80, 33, -45), + curve = EllipticCurve.Secp256k1, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 0, + hasBackup = true, + derivedKeys = mapOf( + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 55, -114, -94, -73, -61, -50, 51, -115, 55, -79, 63, -96, -44, -64, -24, -36, -122, -123, -38, 81, -15, -127, -97, -42, 72, -85, -62, -98, 46, 119, 16, -55), + chainCode = byteArrayOf(31, 17, 71, -28, -29, 17, 72, -29, -98, 112, 31, -8, 72, -75, 4, -11, 60, -100, 9, 35, 58, -42, -38, 96, -71, -68, 24, -119, -43, -18, -122, 72), + ), + DerivationPath("m/84'/0'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -26, 103, 96, 112, 127, -125, 0, 7, 53, 30, -12, -82, 45, 14, 107, -9, 126, 75, 104, -67, -49, 35, -12, -82, -90, 101, -101, 125, -76, 88, -54, 99), + chainCode = byteArrayOf(-42, 36, 97, 65, -64, -113, 76, -91, -9, 11, 89, 123, -9, -3, 21, 103, -113, -60, 48, -31, -34, 108, 111, -38, -110, -80, 109, 17, -29, 2, 45, -71), + ), + DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( + publicKey = byteArrayOf(2, -96, -3, -83, 101, 63, -25, -125, -4, -65, -42, -56, 24, -52, 118, -11, -104, -105, 40, -59, 20, -109, -97, 29, -95, -6, -80, 2, 67, 103, -80, -22, -94), + chainCode = byteArrayOf(-125, 27, -91, 38, -66, 109, -92, 16, -37, 93, 107, -29, -128, -1, 115, -64, 108, -63, 17, 27, 58, 78, -2, 39, 88, -39, 44, 89, 32, -38, -16, -38), + ), + ), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-109, 42, -80, -115, -12, 44, 48, -118, -89, 10, 21, 59, 98, -110, -86, -14, 123, 105, -30, -49, -40, -4, -7, 32, 60, 67, -88, -52, -96, -10, -14, 123), + chainCode = byteArrayOf(-101, -10, -92, 72, 19, 121, -38, 105, -55, -82, -68, 13, -6, 17, 66, -10, -75, 58, 119, -127, 74, 68, 82, 25, 45, -110, 3, 3, 108, -97, -51, -127), + curve = EllipticCurve.Ed25519, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 1, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-64, 18, -128, 121, -89, -37, -99, 44, -125, -72, -111, -79, 7, 85, 40, 67, -39, 117, 123, 11, 105, -6, -5, -79, 19, -10, -29, 20, -14, -40, 5, 90), + chainCode = byteArrayOf(33, -97, 53, -112, 61, 112, -24, 74, -87, -85, -124, -4, 103, -94, -97, 76, -41, -27, 118, 33, 55, 121, -17, -52, 60, 122, 27, 25, 29, -76, 78, 11), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-118, -77, -109, 8, -119, 101, -91, 46, -44, 15, 52, -64, -92, 5, -102, 126, 52, 121, 63, -76, -54, 80, -82, -5, 31, -35, 105, -119, -96, -54, 38, 2, -6, 109, 117, -26, -47, -20, 108, 106, -69, 72, -37, -117, -30, -9, 104, -123), + chainCode = null, + curve = EllipticCurve.Bls12381G2Aug, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 1, + remainingSignatures = null, + index = 2, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = null, + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(17, -78, 53, 101, 96, -110, -10, -48, -84, 88, -64, 58, -26, -13, -2, -6, -25, 23, -79, -45, 58, 77, 52, -75, -123, 121, 32, 92, 84, -74, -111, -8), + chainCode = byteArrayOf(-110, 73, -124, -65, -1, -70, 64, -89, 95, -74, 15, 46, -110, 87, 15, -61, 120, -51, 111, 111, -45, 37, 123, -114, 65, -116, 21, 39, -77, 86, -3, -26), + curve = EllipticCurve.Bip0340, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 3, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(115, 14, 11, 0, -93, 81, -103, -95, -75, -84, 18, -120, -31, 76, -83, -81, 91, 25, -75, 36, -99, -53, -25, -15, -1, -57, 14, -39, 98, -116, -63, -123), + chainCode = byteArrayOf(23, 5, 38, -48, 67, -42, -31, -21, 89, 11, 22, -28, 44, -19, -115, -78, 123, -27, 57, 57, -24, -86, 55, 15, 104, 114, -36, 80, 81, -108, -41, 112), + ), + isImported = false, + ), + CardDTO.Wallet( + publicKey = byteArrayOf(-52, -92, 64, 108, -8, -100, 87, -86, -60, 18, -18, -81, 114, -84, 76, -24, 84, 52, -34, 79, -30, -66, -112, -55, -35, -119, 127, -109, 35, 18, -29, -25), + chainCode = byteArrayOf(94, -33, -94, -63, 40, -20, -26, 71, 111, 9, 87, -36, -42, -33, 40, -53, 85, 31, -36, -29, 65, -121, 2, -23, -119, -51, 114, -17, -39, -74, 23, -97), + curve = EllipticCurve.Ed25519Slip0010, + settings = CardWallet.Settings(isPermanent = false), + totalSignedHashes = 0, + remainingSignatures = null, + index = 4, + hasBackup = true, + derivedKeys = emptyMap(), + extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(-43, 1, -81, -47, -8, -103, -66, 42, 37, -7, 65, 54, 57, -24, 127, -89, 69, -112, 42, -46, -128, 36, -117, -28, 30, -48, 37, 52, 93, -47, 92, -47), + chainCode = byteArrayOf(4, -97, 81, -37, 76, -67, -87, -4, -82, -36, -45, -28, -117, -59, -62, 93, -73, 50, 65, -91, -83, 25, -95, 89, -64, -40, 113, 28, 59, 113, -99, 89), + ), + isImported = false, + ), + ), + attestation = Attestation( + cardKeyAttestation = Attestation.Status.Verified, + walletKeysAttestation = Attestation.Status.Skipped, + firmwareAttestation = Attestation.Status.Skipped, + cardUniquenessAttestation = Attestation.Status.Skipped, + ), + backupStatus = CardDTO.BackupStatus.Active(2), + ) + + override val scanResponse = ScanResponse( + card = cardDto, + productType = ProductType.Wallet2, + walletData = null, + secondTwinPublicKey = null, + derivedKeys = emptyMap(), + primaryCard = null, + ) + + override val derivationTaskResponse = DerivationTaskResponse( + entries = mapOf( + ByteArrayKey( + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch + publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), + chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/3'/0'/0/0") to ExtendedPublicKey( // doge + publicKey = byteArrayOf(3, -25, -24, -97, -124, 24, -89, 44, 75, 123, 92, -86, -73, -93, 25, -90, -89, -95, 88, 3, 107, 37, -1, -85, -32, -57, -123, -41, 108, -9, -96, 77, -124), + chainCode = byteArrayOf(119, 3, 41, 112, 71, 54, 72, 30, 39, 25, 25, -104, 92, 46, -109, 63, 93, 67, 43, -102, -87, 39, -95, 106, 45, 67, 109, -29, -35, 10, -107, 104), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + ) + + override val extendedPublicKey = ExtendedPublicKey( + publicKey = byteArrayOf(2, -93, -36, -105, 121, -52, -30, -43, -67, -7, -31, -26, -35, 25, 99, 25, -20, 118, -20, -125, -89, 12, -101, -86, 74, -91, 23, -24, 93, -86, 20, -53, -8), + chainCode = byteArrayOf(32, 60, 63, -96, 97, 58, 121, 108, 75, 59, 63, -113, 60, -49, 47, 33, 15, -65, -69, 45, -7, -26, 65, -5, -91, -55, -42, -102, -127, -104, -111, 96), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ) + + override val successResponse = SuccessResponse(cardId = "BB00003800000000") + + override val createProductWalletTaskResponse = CreateProductWalletTaskResponse( + card = cardDto, + derivedKeys = mapOf( + ByteArrayKey( + byteArrayOf(3, 23, -112, -57, 109, 60, -82, -36, 45, -14, -34, -12, 10, -89, 14, 37, 38, 36, -102, 37, 93, 90, 69, -113, -117, 120, -29, 12, -125, 43, -40, -31, 5), + ) + to + ExtendedPublicKeysMap( + mapOf( + DerivationPath("m/44'/0'/0'/0/0") to ExtendedPublicKey( // btc + publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52), + chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + ), + ), + ), + primaryCard = primaryCard, + ) + + override val importWalletResponse: CreateProductWalletTaskResponse + get() = TODO("Not yet implemented") + + override val createFirstTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val createSecondTwinResponse: CreateWalletResponse + get() = error("Available only for Twin") + + override val finalizeTwinResponse: ScanResponse + get() = error("Available only for Twin") +} \ No newline at end of file From eb2c1d765aca2bf740b517d9dbae0d0f1280a016 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Apr 2026 08:15:22 -0700 Subject: [PATCH 048/206] Updated on 2026-08-14 --- .../tangem/datasource/api/pay/TangemPayApi.kt | 12 + .../pay/models/request/ReissueCardRequest.kt | 9 + .../api/pay/models/response/FeeResponse.kt | 17 ++ .../models/response/ReissueCardResponse.kt | 15 ++ .../datasource/di/TangemPayStoresModule.kt | 10 + .../visa/DefaultTangemPayReissueCardStore.kt | 30 +++ .../local/visa/TangemPayReissueCardStore.kt | 15 ++ core/res/src/main/res/values/strings.xml | 11 + .../ui/src/main/res/drawable/ic_update_32.xml | 9 + core/ui/src/main/res/drawable/img_usdc_16.xml | 18 ++ .../tangem/data/pay/di/TangemPayDataModule.kt | 5 + .../DefaultReissueCardRepository.kt | 104 +++++++++ .../domain/models/TangemPayReissueCardFee.kt | 8 + .../tangem/domain/pay/model/OrderStatus.kt | 13 ++ .../pay/model/TangemPayReissueOrderInfo.kt | 6 + .../TangemPayReissueCardRepository.kt | 22 ++ .../tangempay/TangemPayAnalyticsEvents.kt | 15 ++ .../DefaultTangemPayCardPageComponent.kt | 3 + .../TangemPayCardPageScreenComponent.kt | 33 ++- .../components/TangemPayDetailsComponent.kt | 1 + .../TangemPayReissueCardComponent.kt | 40 ++++ .../tangempay/di/TangemPayModelModule.kt | 6 + .../tangempay/entity/TangemPayCardPageUM.kt | 28 ++- .../entity/TangemPayDetailsNavigation.kt | 3 + .../tangempay/entity/TangemPayDetailsUM.kt | 1 + .../entity/TangemPayReissueCardUM.kt | 37 +++ .../tangempay/model/TangemPayCardPageModel.kt | 131 ++++++++++- .../model/TangemPayReissueCardModel.kt | 119 ++++++++++ .../tangempay/ui/TangemPayCardDetailsBlock.kt | 179 +++++++------- .../tangempay/ui/TangemPayCardPageScreen.kt | 39 ++-- .../ui/TangemPayReissueCardContent.kt | 218 ++++++++++++++++++ 31 files changed, 1034 insertions(+), 123 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ReissueCardRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FeeResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt create mode 100644 core/ui/src/main/res/drawable/ic_update_32.xml create mode 100644 core/ui/src/main/res/drawable/img_usdc_16.xml create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayReissueCardUM.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContent.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 3c1f040b64..bcbcd299b2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -85,6 +85,18 @@ interface TangemPayApi { @Body body: FreezeUnfreezeCardRequest, ): ApiResponse + @GET("v1/fees/{type}") + suspend fun getFee( + @Header("Authorization") authHeader: String, + @Path("type") type: String, + ): ApiResponse + + @POST("v1/customer/card/reissue") + suspend fun reissueCard( + @Header("Authorization") authHeader: String, + @Body body: ReissueCardRequest, + ): ApiResponse + @POST("v1/customer/card/withdraw/data") suspend fun getWithdrawData( @Header("Authorization") authHeader: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ReissueCardRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ReissueCardRequest.kt new file mode 100644 index 0000000000..26b2f0dd53 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/ReissueCardRequest.kt @@ -0,0 +1,9 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ReissueCardRequest( + @Json(name = "card_id") val cardId: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FeeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FeeResponse.kt new file mode 100644 index 0000000000..f18a34f075 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/FeeResponse.kt @@ -0,0 +1,17 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class FeeResponse( + @Json(name = "result") val result: Result, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "type") val type: String, + @Json(name = "amount") val amount: String, + @Json(name = "currency") val currency: String, + @Json(name = "description") val description: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt new file mode 100644 index 0000000000..49fb34198e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ReissueCardResponse( + @Json(name = "result") val result: Result, +) { + @JsonClass(generateAdapter = true) + data class Result( + @Json(name = "order_id") val orderId: String, + @Json(name = "status") val status: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt index ae3097d006..135c8bfaa8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt @@ -2,7 +2,9 @@ package com.tangem.datasource.di import com.tangem.datasource.local.datastore.RuntimeDataStore import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore +import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore +import com.tangem.datasource.local.visa.TangemPayReissueCardStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,4 +22,12 @@ internal object TangemPayStoresModule { dataStore = RuntimeDataStore(), ) } + + @Provides + @Singleton + fun provideTangemPayReissueCardStore(): TangemPayReissueCardStore { + return DefaultTangemPayReissueCardStore( + feeStore = RuntimeDataStore(), + ) + } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt new file mode 100644 index 0000000000..d179f07eb3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt @@ -0,0 +1,30 @@ +package com.tangem.datasource.local.visa + +import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.domain.models.TangemPayReissueCardFee +import com.tangem.domain.models.wallet.UserWalletId + +internal class DefaultTangemPayReissueCardStore( + private val feeStore: RuntimeDataStore, +) : TangemPayReissueCardStore { + + override suspend fun storeReissueFee( + userWalletId: UserWalletId, + tangemPayReissueCardFee: TangemPayReissueCardFee, + ) { + feeStore.store(userWalletId.stringValue, tangemPayReissueCardFee) + } + + override suspend fun getReissueFee(userWalletId: UserWalletId): TangemPayReissueCardFee? { + return feeStore.getSyncOrNull(userWalletId.stringValue) + } + + override suspend fun storeReissueOrderId(cardId: String, orderId: String) { + // TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs + } + + override suspend fun getOrderId(cardId: String): String? { + // TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs + return null + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt new file mode 100644 index 0000000000..bc7825051f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.local.visa + +import com.tangem.domain.models.TangemPayReissueCardFee +import com.tangem.domain.models.wallet.UserWalletId + +interface TangemPayReissueCardStore { + + suspend fun storeReissueFee(userWalletId: UserWalletId, tangemPayReissueCardFee: TangemPayReissueCardFee) + + suspend fun getReissueFee(userWalletId: UserWalletId): TangemPayReissueCardFee? + + suspend fun storeReissueOrderId(cardId: String, orderId: String) + + suspend fun getOrderId(cardId: String): String? +} \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 93d7812b80..4f79cdf591 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1556,6 +1556,17 @@ Explore transaction Service fees Fee + Replace card + Replace your card? + This generates a new set of card details. Your old details will stop working. You can\'t undo this. + Replacement fee + Replace card + Replacing your digital card + Usually takes up to 5 minutes. In rare cases, up to 48 hours. + Insufficient funds to replace the card + Unable to cover fee + Deposit USDC to payment account to cover the issuing fee + Replacement fee info unreachable Keep your money safe. You can unfreeze anytime. Freeze your card? Failed to freeze the card. Try again later. diff --git a/core/ui/src/main/res/drawable/ic_update_32.xml b/core/ui/src/main/res/drawable/ic_update_32.xml new file mode 100644 index 0000000000..3be862185a --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_update_32.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/img_usdc_16.xml b/core/ui/src/main/res/drawable/img_usdc_16.xml new file mode 100644 index 0000000000..460fa8ad13 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_usdc_16.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index d697221c1b..7d55c0625d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -10,6 +10,7 @@ import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer import com.tangem.data.pay.repository.* +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase @@ -71,6 +72,10 @@ internal interface TangemPayDataModule { @Singleton fun bindCustomerOrderRepository(repository: DefaultCustomerOrderRepository): CustomerOrderRepository + @Binds + @Singleton + fun bindReissueCardRepository(repository: DefaultReissueCardRepository): TangemPayReissueCardRepository + @Binds @Singleton fun bindTangemPayCryptoCurrencyFactory( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt new file mode 100644 index 0000000000..abffaa87fc --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt @@ -0,0 +1,104 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.raise.either +import arrow.core.right +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.pay.TangemPayApi +import com.tangem.datasource.api.pay.models.request.ReissueCardRequest +import com.tangem.datasource.api.pay.models.response.OrderResponse +import com.tangem.datasource.local.visa.TangemPayReissueCardStore +import com.tangem.domain.models.TangemPayReissueCardFee +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.utils.coroutines.runSuspendCatching +import javax.inject.Inject + +internal class DefaultReissueCardRepository @Inject constructor( + private val tangemPayApi: TangemPayApi, + private val requestHelper: TangemPayRequestPerformer, + private val tangemPayReissueCardStore: TangemPayReissueCardStore, +) : TangemPayReissueCardRepository { + + override suspend fun getReissueCardFee(userWalletId: UserWalletId): Either = + either { + runSuspendCatching { + tangemPayReissueCardStore.getReissueFee(userWalletId)?.let { return Either.Right(it) } + } + + val response = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getFee( + authHeader = authHeader, + type = CARD_REPLACEMENT_FEE_TYPE, + ) + }.bind() + + val result = response.result + val fee = TangemPayReissueCardFee( + amount = result.amount.toBigDecimal(), + currencyCode = result.currency, + ) + + runSuspendCatching { + tangemPayReissueCardStore.storeReissueFee(userWalletId, fee) + } + + fee + } + + override suspend fun reissueCard( + userWalletId: UserWalletId, + cardId: String, + ): Either = either { + val response = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.reissueCard( + authHeader = authHeader, + body = ReissueCardRequest(cardId = cardId), + ) + }.bind() + + TangemPayReissueOrderInfo(response.result.orderId, OrderStatus.fromString(response.result.status)) + } + + override suspend fun storeReissueOrderId(cardId: String, orderId: String): Either = + runSuspendCatching { + tangemPayReissueCardStore.storeReissueOrderId(cardId, orderId) + }.fold( + onSuccess = { Unit.right() }, + onFailure = { Either.Left(VisaApiError.Unspecified) }, + ) + + override suspend fun getReissueOrderInfo( + userWalletId: UserWalletId, + cardId: String, + ): Either = either { + val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull() + + if (orderId == null) { + return null.right() + } + + val order = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getOrder(authHeader, orderId) + }.bind() + + val result = order.result ?: raise(VisaApiError.Unspecified) + + TangemPayReissueOrderInfo( + orderId = result.id, + orderStatus = when (result.status) { + OrderResponse.Result.Status.NEW -> OrderStatus.NEW + OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING + OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED + OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED + }, + ) + } + + private companion object { + const val CARD_REPLACEMENT_FEE_TYPE = "CARD_REPLACEMENT" + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt new file mode 100644 index 0000000000..515857f8a7 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt @@ -0,0 +1,8 @@ +package com.tangem.domain.models + +import java.math.BigDecimal + +data class TangemPayReissueCardFee( + val amount: BigDecimal, + val currencyCode: String, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index 327d8fd61f..c304832170 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -1,9 +1,22 @@ package com.tangem.domain.pay.model +import java.util.Locale + enum class OrderStatus { UNKNOWN, // TODO remove it after TangemPay accounts refactor TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED NEW, PROCESSING, COMPLETED, CANCELED, + ; + + companion object { + fun fromString(value: String) = when (value.uppercase(Locale.US)) { + "NEW" -> NEW + "PROCESSING" -> PROCESSING + "COMPLETED" -> COMPLETED + "CANCELED" -> CANCELED + else -> UNKNOWN + } + } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt new file mode 100644 index 0000000000..45945f7204 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.pay.model + +data class TangemPayReissueOrderInfo( + val orderId: String, + val orderStatus: OrderStatus, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt new file mode 100644 index 0000000000..2342faff2c --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.pay.repository + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.TangemPayReissueCardFee +import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.visa.error.VisaApiError + +interface TangemPayReissueCardRepository { + + suspend fun getReissueCardFee(userWalletId: UserWalletId): Either + + suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either + + suspend fun storeReissueOrderId(cardId: String, orderId: String): Either + + suspend fun getReissueOrderInfo( + userWalletId: UserWalletId, + cardId: String, + ): Either +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index f7eb452cc2..03f5d83bfe 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -192,6 +192,21 @@ sealed class TangemPayAnalyticsEvents( event = "Visa KYC Canceled", ) + class ReplaceCardClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Visa Replace Card Clicked", + ) + + class ReplaceCardConfirmationPopupOpened : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Visa Replace Card Confirmation Popup Opened", + ) + + class ReplaceCardConfirmed : TangemPayAnalyticsEvents( + categoryName = "Visa Screen", + event = "Visa Replace Card Confirmed", + ) + class MainVisaPermanentBannerClicked : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa Permanent Banner Clicked", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt index 7b9e24e79b..de2c4bf4a2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt @@ -14,6 +14,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -21,6 +22,7 @@ import dagger.assisted.AssistedInject internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: TangemPayCardPageComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, ) : AppComponentContext by appComponentContext, TangemPayCardPageComponent { private val stackNavigation = StackNavigation() @@ -56,6 +58,7 @@ internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( TangemPayDetailsInnerRoute.Details -> TangemPayCardPageScreenComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = params, + tokenReceiveComponentFactory = tokenReceiveComponentFactory, ) TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt index e0f2994b3c..039bcab003 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext @@ -19,10 +20,12 @@ import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetails import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.model.TangemPayCardPageModel import com.tangem.features.tangempay.ui.TangemPayCardPageScreen +import com.tangem.features.tokenreceive.TokenReceiveComponent internal class TangemPayCardPageScreenComponent( private val appComponentContext: AppComponentContext, private val params: TangemPayCardPageComponent.Params, + private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, ) : AppComponentContext by appComponentContext, ComposableContentComponent { private val model: TangemPayCardPageModel = getOrCreateModel(params = params) @@ -57,7 +60,9 @@ internal class TangemPayCardPageScreenComponent( TangemPayCardPageScreen( state = state, cardDetailsBlockComponent = cardDetailsBlockComponent, - cardDetailsState = cardDetailsState, + cardDetailsState = cardDetailsState.copy( + isActive = !state.isReissueInProgress, + ), modifier = modifier, ) bottomSheet.child?.instance?.BottomSheet() @@ -77,6 +82,32 @@ internal class TangemPayCardPageScreenComponent( listener = model, ), ) + is TangemPayDetailsNavigation.ReissueCard -> TangemPayReissueCardComponent( + appComponentContext = context, + params = TangemPayReissueCardComponent.Params( + listener = model, + userWalletId = params.userWalletId, + cardId = params.config.cardId, + ), + ) + is TangemPayDetailsNavigation.AddFunds -> TangemPayAddFundsComponent( + appComponentContext = context, + params = TangemPayAddFundsComponent.Params( + listener = model, + walletId = navigation.walletId, + cryptoBalance = navigation.cryptoBalance, + fiatBalance = navigation.fiatBalance, + depositAddress = navigation.depositAddress, + chainId = navigation.chainId, + ), + ) + is TangemPayDetailsNavigation.Receive -> tokenReceiveComponentFactory.create( + context = context, + params = TokenReceiveComponent.Params( + config = navigation.config, + onDismiss = model.bottomSheetNavigation::dismiss, + ), + ) else -> error("Unsupported bottom sheet navigation: $navigation") } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index c193b44545..b59577ebe8 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -130,6 +130,7 @@ internal class TangemPayDetailsComponent( listener = model, ), ) + else -> error("Unsupported bottom sheet navigation: $navigation") } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt new file mode 100644 index 0000000000..13f1c7c061 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt @@ -0,0 +1,40 @@ +package com.tangem.features.tangempay.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.features.tangempay.model.TangemPayReissueCardModel +import com.tangem.features.tangempay.ui.TangemPayReissueCardContent + +internal class TangemPayReissueCardComponent( + appComponentContext: AppComponentContext, + private val params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: TangemPayReissueCardModel = getOrCreateModel(params = params) + + override fun dismiss() = model.onDismiss() + + @Composable + override fun BottomSheet() { + val state by model.state.collectAsStateWithLifecycle() + TangemPayReissueCardContent(state = state) + } + + data class Params( + val listener: ReissueCardListener, + val userWalletId: UserWalletId, + val cardId: String, + ) +} + +internal interface ReissueCardListener { + fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) + fun onDismissReissueCard() + fun onClickAddFunds() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index 623eea9c2a..d9b0f67529 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -11,6 +11,7 @@ import com.tangem.features.tangempay.model.TangemPayDetailsModel import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel import com.tangem.features.tangempay.model.TangemPayTxHistoryDetailsModel import com.tangem.features.tangempay.model.TangemPayTxHistoryModel +import com.tangem.features.tangempay.model.TangemPayReissueCardModel import com.tangem.features.tangempay.model.TangemPayViewPinModel import dagger.Binds import dagger.Module @@ -71,4 +72,9 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPayEditDisplayNameModel::class) fun bindTangemPayEditDisplayNameModel(model: TangemPayEditDisplayNameModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayReissueCardModel::class) + fun bindTangemPayReissueCardModel(model: TangemPayReissueCardModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt index 8a16bf6d94..93bda7eba7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt @@ -1,39 +1,37 @@ package com.tangem.features.tangempay.entity import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Immutable internal data class TangemPayCardPageUM( - val addToWalletBlockState: AddToWalletBlockState? = null, - val settings: ImmutableList = persistentListOf( - TangemPayCardPageSetting.ChangePIN, - TangemPayCardPageSetting.FreezeCard, - ), + val settings: ImmutableList, val onBackClick: () -> Unit, - val onSettingClick: (TangemPayCardPageSetting) -> Unit, + val addToWalletBlockState: AddToWalletBlockState? = null, + val isReissueInProgress: Boolean = false, ) { companion object { fun stub( addToWalletBlockState: AddToWalletBlockState? = AddToWalletBlockState(onClick = {}, onClickClose = {}), settings: ImmutableList = persistentListOf( - TangemPayCardPageSetting.ChangePIN, - TangemPayCardPageSetting.FreezeCard, - TangemPayCardPageSetting.ReplaceCard, + TangemPayCardPageSetting(TextReference.Str("Pin Code")) {}, + TangemPayCardPageSetting(TextReference.Str("Freeze Card")) {}, + TangemPayCardPageSetting(TextReference.Str("Reissue Card")) {}, ), + isReissueInProgress: Boolean = false, ) = TangemPayCardPageUM( addToWalletBlockState = addToWalletBlockState, settings = settings, onBackClick = {}, - onSettingClick = {}, + isReissueInProgress = isReissueInProgress, ) } } @Immutable -internal sealed class TangemPayCardPageSetting { - data object ChangePIN : TangemPayCardPageSetting() - data object FreezeCard : TangemPayCardPageSetting() - data object ReplaceCard : TangemPayCardPageSetting() -} \ No newline at end of file +internal data class TangemPayCardPageSetting( + val title: TextReference, + val onSettingClick: () -> Unit, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt index 5f10a9ed77..fdc5357eae 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt @@ -32,4 +32,7 @@ internal sealed class TangemPayDetailsNavigation { val userWalletId: UserWalletId, val cardId: String, ) : TangemPayDetailsNavigation() + + @Serializable + data object ReissueCard : TangemPayDetailsNavigation() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 5660713341..7eb7b01df6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -31,6 +31,7 @@ internal data class TangemPayCardDetailsUM( val isLoading: Boolean = false, val cardFrozenState: TangemPayCardFrozenState, val displayNameState: DisplayNameState?, + val isActive: Boolean = true, ) internal sealed interface DisplayNameState { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayReissueCardUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayReissueCardUM.kt new file mode 100644 index 0000000000..f08efa4032 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayReissueCardUM.kt @@ -0,0 +1,37 @@ +package com.tangem.features.tangempay.entity + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class TangemPayReissueCardUM( + val feeAmount: String, + val isFeeLoading: Boolean, + val isReissuingInProgress: Boolean, + val error: TangemPayReissueCardError?, + val onConfirmClick: () -> Unit, + val onRetryFee: () -> Unit, + val onAddFundsClick: () -> Unit, + val onDismissRequest: () -> Unit, +) { + companion object { + fun stub( + feeAmount: String = "$4.25", + isFeeLoading: Boolean = false, + error: TangemPayReissueCardError = TangemPayReissueCardError.InitialDataLoading, + isReissuingInProgress: Boolean = false, + ) = TangemPayReissueCardUM( + feeAmount = feeAmount, + isFeeLoading = isFeeLoading, + error = error, + isReissuingInProgress = isReissuingInProgress, + onConfirmClick = {}, + onRetryFee = {}, + onAddFundsClick = {}, + onDismissRequest = {}, + ) + } +} + +internal enum class TangemPayReissueCardError { + InsufficientFunds, InitialDataLoading +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index b08ca54fbb..681148be62 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -4,15 +4,27 @@ import androidx.compose.runtime.Stable import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam 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.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.components.AddFundsListener +import com.tangem.features.tangempay.components.ReissueCardListener import com.tangem.features.tangempay.components.TangemPayCardPageComponent import com.tangem.features.tangempay.components.ViewPinListener import com.tangem.features.tangempay.details.impl.R @@ -25,6 +37,7 @@ import com.tangem.features.tangempay.utils.TangemPayMessagesFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn @@ -33,43 +46,55 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayCardPageModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, + private val analytics: AnalyticsEventHandler, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val uiMessageSender: UiMessageSender, -) : Model(), ViewPinListener { + private val reissueCardRepository: TangemPayReissueCardRepository, +) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener { private val params: TangemPayCardPageComponent.Params = paramsContainer.require() private var currentFrozenState: TangemPayCardFrozenState = params.config.cardFrozenState private val addToWalletBannerJobHolder = JobHolder() + private val addFundsJobHolder = JobHolder() val uiState: StateFlow field = MutableStateFlow( TangemPayCardPageUM( onBackClick = router::pop, - onSettingClick = ::onSettingClick, + settings = persistentListOf( + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_change_pin), + onSettingClick = ::onClickChangePIN, + ), + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_freeze_card), + onSettingClick = ::onClickFreezeOrUnfreezeCard, + ), + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_reissue_card), + onSettingClick = ::onClickReissueCard, + ), + ), ), ) val bottomSheetNavigation: SlotNavigation = SlotNavigation() init { + // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed fetchAddToWalletBanner() subscribeToCardFrozenState() } - private fun onSettingClick(setting: TangemPayCardPageSetting) = when (setting) { - TangemPayCardPageSetting.ChangePIN -> onClickChangePIN() - TangemPayCardPageSetting.FreezeCard -> onClickFreezeOrUnfreezeCard() - TangemPayCardPageSetting.ReplaceCard -> Unit // TODO v_rodionov #[REDACTED_TASK_KEY] - } - private fun onClickChangePIN() { if (!params.config.isPinSet) { router.push(TangemPayDetailsInnerRoute.ChangePIN) @@ -94,6 +119,82 @@ internal class TangemPayCardPageModel @Inject constructor( } } + private fun onClickReissueCard() { + analytics.send(TangemPayAnalyticsEvents.ReplaceCardClicked()) + bottomSheetNavigation.activate(TangemPayDetailsNavigation.ReissueCard) + } + + override fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) { + bottomSheetNavigation.dismiss() + onReissueOrderStatusReceived(order.orderStatus) + if (order.orderStatus != OrderStatus.CANCELED) { + modelScope.launch { + reissueCardRepository.storeReissueOrderId(params.config.cardId, order.orderId) + } + } else { + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong))) + } + } + + override fun onDismissReissueCard() { + bottomSheetNavigation.dismiss() + } + + override fun onClickAddFunds() { + bottomSheetNavigation.dismiss() + modelScope.launch { + val balance = cardDetailsRepository.getCardBalance(params.userWalletId).getOrNull() + val depositAddress = balance?.depositAddress + if (balance == null || depositAddress == null) { + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_error))) + return@launch + } + bottomSheetNavigation.activate( + TangemPayDetailsNavigation.AddFunds( + walletId = params.userWalletId, + fiatBalance = balance.fiatBalance, + cryptoBalance = balance.cryptoBalance, + depositAddress = depositAddress, + chainId = params.config.chainId, + ), + ) + }.saveIn(addFundsJobHolder) + } + + override fun onClickReceive(data: TangemPayTopUpData) { + bottomSheetNavigation.dismiss() + val config = TokenReceiveConfig( + shouldShowWarning = true, + cryptoCurrency = data.currency, + userWalletId = data.walletId, + showMemoDisclaimer = false, + receiveAddress = data.receiveAddress, + ) + bottomSheetNavigation.activate(TangemPayDetailsNavigation.Receive(config)) + } + + override fun onClickSwap(data: TangemPayTopUpData) { + bottomSheetNavigation.dismiss() + router.push( + AppRoute.Swap( + currencyFrom = data.currency, + userWalletId = data.walletId, + isInitialReverseOrder = true, + screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + tangemPayInput = AppRoute.Swap.TangemPayInput( + cryptoAmount = data.cryptoBalance, + fiatAmount = data.fiatBalance, + depositAddress = data.depositAddress, + isWithdrawal = false, + ), + ), + ) + } + + override fun onDismissAddFunds() { + bottomSheetNavigation.dismiss() + } + private fun freezeCard() { modelScope.launch { cardDetailsRepository.freezeCard( @@ -174,4 +275,18 @@ internal class TangemPayCardPageModel @Inject constructor( override fun onDismissViewPin() { bottomSheetNavigation.dismiss() } + + private fun onReissueOrderStatusReceived(orderStatus: OrderStatus) { + when (orderStatus) { + OrderStatus.NEW, OrderStatus.PROCESSING, OrderStatus.COMPLETED, OrderStatus.UNKNOWN -> { + uiState.update { state -> + state.copy( + addToWalletBlockState = null, + isReissueInProgress = true, + ) + } + } + OrderStatus.CANCELED -> Unit + } + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt new file mode 100644 index 0000000000..6c454671a1 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt @@ -0,0 +1,119 @@ +package com.tangem.features.tangempay.model + +import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler +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.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.features.tangempay.components.TangemPayReissueCardComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayReissueCardError +import com.tangem.features.tangempay.entity.TangemPayReissueCardUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveIn +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayReissueCardModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val reissueCardRepository: TangemPayReissueCardRepository, + private val uiMessageSender: UiMessageSender, + private val analytics: AnalyticsEventHandler, +) : Model() { + + private val params = paramsContainer.require() + private val reissueJobHolder = JobHolder() + private val loadDataJobHolder = JobHolder() + + val state: StateFlow + field = MutableStateFlow( + TangemPayReissueCardUM( + feeAmount = "", + isFeeLoading = true, + error = null, + isReissuingInProgress = false, + onConfirmClick = ::onConfirm, + onRetryFee = ::loadData, + onAddFundsClick = { params.listener.onClickAddFunds() }, + onDismissRequest = ::onDismiss, + ), + ) + + init { + analytics.send(TangemPayAnalyticsEvents.ReplaceCardConfirmationPopupOpened()) + loadData() + } + + fun onDismiss() { + reissueJobHolder.cancel() + params.listener.onDismissReissueCard() + } + + private fun onConfirm() { + analytics.send(TangemPayAnalyticsEvents.ReplaceCardConfirmed()) + state.update { it.copy(isReissuingInProgress = true) } + modelScope.launch { + reissueCardRepository.reissueCard( + userWalletId = params.userWalletId, + cardId = params.cardId, + ).onLeft { + uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong))) + onDismiss() + }.onRight { order -> + params.listener.onReissueOrderCreate(order) + } + }.saveIn(reissueJobHolder) + } + + private fun loadData() { + state.update { it.copy(isFeeLoading = true, error = null) } + + modelScope.launch { + val (cardBalance, fee) = coroutineScope { + val balanceDeferred = async { cardDetailsRepository.getCardBalance(params.userWalletId).getOrNull() } + val feeDeferred = async { reissueCardRepository.getReissueCardFee(params.userWalletId).getOrNull() } + balanceDeferred.await() to feeDeferred.await() + } + + val error = if (fee == null || cardBalance == null) { + TangemPayReissueCardError.InitialDataLoading + } else if (cardBalance.availableForWithdrawal < fee.amount) { + TangemPayReissueCardError.InsufficientFunds + } else { + null + } + + state.update { state -> + state.copy( + feeAmount = fee?.let { + fee.amount.format { + val symbol = getJavaCurrencyByCode(fee.currencyCode).symbol + fiat(fee.currencyCode, symbol) + } + }.orEmpty(), + isFeeLoading = false, + error = error, + ) + } + }.saveIn(loadDataJobHolder) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 9cc9ea437d..b2509b7b49 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -12,12 +12,27 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -27,8 +42,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Brush @@ -45,6 +58,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.buttons.common.TangemButton import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition @@ -53,12 +68,12 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.model.CardDataType -import com.tangem.domain.models.account.CardDisplayName private const val ICON_FADE_DURATION_MS = 300 private val CustomCardBlockColor = Color(0x1F828282) @@ -101,7 +116,7 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M shape = RoundedCornerShape(16.dp), ), ) { - if (shouldShowDetails) { + if (shouldShowDetails && state.isActive) { TangemPayCardDetailsShownBlock( cardNumber = state.number, expiry = state.expiry, @@ -114,11 +129,7 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M ) } else { TangemPayCardDetailsHiddenBlock( - cardFrozenState = state.cardFrozenState, - isLoading = state.isLoading, - shortCardNumber = state.numberShort, - onShowDetails = state.onClick, - displayNameState = state.displayNameState, + state = state, ) } } @@ -126,16 +137,9 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M @Suppress("LongMethod", "DestructuringDeclarationWithTooManyEntries") @Composable -private fun TangemPayCardDetailsHiddenBlock( - shortCardNumber: String, - cardFrozenState: TangemPayCardFrozenState, - isLoading: Boolean, - onShowDetails: () -> Unit, - displayNameState: DisplayNameState?, - modifier: Modifier = Modifier, -) { +private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modifier: Modifier = Modifier) { Box(modifier = modifier.fillMaxSize()) { - val imageResId = when (cardFrozenState) { + val imageResId = when (state.cardFrozenState) { is TangemPayCardFrozenState.Frozen -> R.drawable.img_tangem_pay_visa_frozen else -> R.drawable.img_tangem_pay_visa } @@ -144,75 +148,78 @@ private fun TangemPayCardDetailsHiddenBlock( painter = painterResource(id = imageResId), contentDescription = null, ) - ConstraintLayout( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 16.dp) - .padding(bottom = 8.dp) - .fillMaxWidth(), - ) { - val (displayNameRef, cardNumberRef, frozenIconRef, buttonRef) = createRefs() - if (displayNameState != null) { - CardDisplayName( - state = displayNameState, - modifier = Modifier.constrainAs(displayNameRef) { - start.linkTo(parent.start) - bottom.linkTo(cardNumberRef.top) - width = Dimension.wrapContent - }, - ) - } - - Text( - text = shortCardNumber, - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.constantWhite, + if (state.isActive) { + ConstraintLayout( modifier = Modifier - .constrainAs(cardNumberRef) { - start.linkTo(parent.start) - bottom.linkTo(parent.bottom) - } - .padding(bottom = 8.dp), - ) - when (cardFrozenState) { - is TangemPayCardFrozenState.Frozen -> Icon( - modifier = Modifier - .constrainAs(frozenIconRef) { - start.linkTo(cardNumberRef.end, margin = 4.dp) - top.linkTo(cardNumberRef.top) - bottom.linkTo(cardNumberRef.bottom) - } - .padding(bottom = 8.dp) - .size(16.dp), - painter = painterResource(id = R.drawable.ic_snow_24), - contentDescription = null, - tint = TangemTheme.colors.icon.constant, - ) - TangemPayCardFrozenState.Pending -> CircularProgressIndicator( - modifier = Modifier - .constrainAs(frozenIconRef) { - start.linkTo(cardNumberRef.end, margin = 4.dp) - top.linkTo(cardNumberRef.top) - bottom.linkTo(cardNumberRef.bottom) - } - .padding(bottom = 8.dp) - .size(16.dp), - color = TangemTheme.colors.text.constantWhite, - strokeWidth = 1.dp, - ) - TangemPayCardFrozenState.Unfrozen -> Unit - } + .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .fillMaxWidth(), + ) { + val (displayNameRef, cardNumberRef, frozenIconRef, buttonRef) = createRefs() - TangemPayCardDetailsCustomButton( - modifier = Modifier.constrainAs(buttonRef) { - end.linkTo(parent.end) - bottom.linkTo(parent.bottom) - }, - text = stringResourceSafe(id = R.string.tangempay_card_details_show_details), - onClick = onShowDetails, - showProgress = isLoading, - ) + if (state.displayNameState != null) { + CardDisplayName( + state = state.displayNameState, + modifier = Modifier.constrainAs(displayNameRef) { + start.linkTo(parent.start) + bottom.linkTo(cardNumberRef.top) + width = Dimension.wrapContent + }, + ) + } + + Text( + text = state.numberShort, + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.constantWhite, + modifier = Modifier + .constrainAs(cardNumberRef) { + start.linkTo(parent.start) + bottom.linkTo(parent.bottom) + } + .padding(bottom = 8.dp), + ) + when (state.cardFrozenState) { + is TangemPayCardFrozenState.Frozen -> Icon( + modifier = Modifier + .constrainAs(frozenIconRef) { + start.linkTo(cardNumberRef.end, margin = 4.dp) + top.linkTo(cardNumberRef.top) + bottom.linkTo(cardNumberRef.bottom) + } + .padding(bottom = 8.dp) + .size(16.dp), + painter = painterResource(id = R.drawable.ic_snow_24), + contentDescription = null, + tint = TangemTheme.colors.icon.constant, + ) + TangemPayCardFrozenState.Pending -> CircularProgressIndicator( + modifier = Modifier + .constrainAs(frozenIconRef) { + start.linkTo(cardNumberRef.end, margin = 4.dp) + top.linkTo(cardNumberRef.top) + bottom.linkTo(cardNumberRef.bottom) + } + .padding(bottom = 8.dp) + .size(16.dp), + color = TangemTheme.colors.text.constantWhite, + strokeWidth = 1.dp, + ) + TangemPayCardFrozenState.Unfrozen -> Unit + } + + TangemPayCardDetailsCustomButton( + modifier = Modifier.constrainAs(buttonRef) { + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + }, + text = stringResourceSafe(id = R.string.tangempay_card_details_show_details), + onClick = state.onClick, + showProgress = state.isLoading, + ) + } } } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 2dc2540204..673b118af2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -32,6 +32,10 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -46,12 +50,6 @@ import com.tangem.features.tangempay.entity.TangemPayCardPageUM import kotlinx.collections.immutable.ImmutableList private const val CONTENT_FADE_DURATION_MS = 300 -private val TangemPayCardPageSetting.titleRes - get() = when (this) { - TangemPayCardPageSetting.ChangePIN -> R.string.tangempay_card_details_change_pin - TangemPayCardPageSetting.FreezeCard -> R.string.tangempay_card_details_freeze_card - TangemPayCardPageSetting.ReplaceCard -> R.string.common_error // TODO v_rodionov #[REDACTED_TASK_KEY] - } @Composable internal fun TangemPayCardPageScreen( @@ -110,20 +108,35 @@ internal fun TangemPayCardPageScreen( enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)), exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)), ) { - TangemPayCardPageSettingsBlock( - settings = state.settings, - onSettingClick = state.onSettingClick, - ) + if (state.isReissueInProgress) { + TangemPayReplacingCardBlock() + } else { + TangemPayCardPageSettingsBlock( + settings = state.settings, + ) + } } } } } } +@Composable +private fun TangemPayReplacingCardBlock(modifier: Modifier = Modifier) { + Notification( + modifier = modifier, + config = NotificationConfig( + iconResId = com.tangem.core.ui.R.drawable.ic_update_32, + iconTint = NotificationConfig.IconTint.Accent, + title = resourceReference(R.string.tangempay_reissue_card_in_progress), + subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description), + ), + ) +} + @Composable private fun TangemPayCardPageSettingsBlock( settings: ImmutableList, - onSettingClick: (TangemPayCardPageSetting) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -147,7 +160,7 @@ private fun TangemPayCardPageSettingsBlock( settings.fastForEach { item -> TangemPayCardPageSettingRow( item = item, - onClick = { onSettingClick(item) }, + onClick = item.onSettingClick, ) } } @@ -167,7 +180,7 @@ private fun TangemPayCardPageSettingRow( contentAlignment = Alignment.CenterStart, ) { Text( - text = stringResourceSafe(item.titleRes), + text = item.title.resolveReference(), style = TangemTheme.typography.subtitle1, color = TangemTheme.colors.text.primary1, ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContent.kt new file mode 100644 index 0000000000..9048249e1d --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReissueCardContent.kt @@ -0,0 +1,218 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH16 +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.SpacerH8 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet +import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayReissueCardError +import com.tangem.features.tangempay.entity.TangemPayReissueCardUM + +@Composable +internal fun TangemPayReissueCardContent(state: TangemPayReissueCardUM) { + TangemModalBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = state.onDismissRequest, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors.background.tertiary, + onBack = state.onDismissRequest, + title = { + TangemModalBottomSheetTitle( + title = TextReference.EMPTY, + endIconRes = R.drawable.ic_close_24, + onEndClick = state.onDismissRequest, + ) + }, + ) { + Content(state) + } +} + +@Composable +private fun Content(state: TangemPayReissueCardUM) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens.spacing16), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(TangemTheme.dimens.spacing56) + .clip(CircleShape) + .background(TangemTheme.colors.icon.accent.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_update_32), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier.size(32.dp), + ) + } + + SpacerH24() + + Text( + text = stringResourceSafe(R.string.tangempay_reissue_card_title), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + SpacerH8() + + Text( + text = stringResourceSafe(R.string.tangempay_reissue_card_description), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + SpacerH24() + + FeeBlock(state) + + if (state.error != null) { + SpacerH16() + ErrorBlock( + error = state.error, + onRetryFee = state.onRetryFee, + onAddFundsClick = state.onAddFundsClick, + ) + } + + SpacerH24() + + PrimaryButton( + modifier = Modifier.fillMaxWidth(), + text = stringResourceSafe(R.string.tangempay_reissue_card_confirm), + enabled = state.error == null && !state.isFeeLoading, + showProgress = state.isReissuingInProgress, + onClick = state.onConfirmClick, + ) + + SpacerH16() + } +} + +@Composable +private fun FeeBlock(state: TangemPayReissueCardUM) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.action, + shape = TangemTheme.shapes.roundedCornersMedium, + ) + .padding(TangemTheme.dimens.spacing12), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResourceSafe(R.string.tangempay_reissue_card_fee_label), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.primary1, + ) + AnimatedContent( + targetState = when { + state.isFeeLoading -> null + state.error == TangemPayReissueCardError.InitialDataLoading -> "—" + else -> state.feeAmount + }, + ) { fee -> + if (fee != null) { + Text( + text = fee, + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.tertiary, + ) + } else { + TextShimmer(style = TangemTheme.typography.body1, text = "$0.00") + } + } + } +} + +@Composable +private fun ErrorBlock(error: TangemPayReissueCardError, onAddFundsClick: () -> Unit, onRetryFee: () -> Unit) { + when (error) { + TangemPayReissueCardError.InsufficientFunds -> Notification( + config = NotificationConfig( + title = resourceReference(R.string.tangempay_reissue_card_insufficient_funds_title), + subtitle = resourceReference(R.string.tangempay_reissue_card_insufficient_funds_subtitle), + iconResId = R.drawable.img_usdc_16, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.tangempay_card_details_add_funds), + iconResId = R.drawable.ic_plus_24, + onClick = onAddFundsClick, + ), + ), + containerColor = TangemTheme.colors.background.action, + modifier = Modifier.fillMaxWidth(), + ) + TangemPayReissueCardError.InitialDataLoading -> Notification( + config = NotificationConfig( + title = resourceReference(R.string.tangempay_reissue_card_fee_unreachable_error_title), + subtitle = resourceReference(R.string.send_fee_unreachable_error_text), + iconResId = R.drawable.img_attention_20, + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(R.string.warning_button_refresh), + onClick = onRetryFee, + ), + ), + containerColor = TangemTheme.colors.background.action, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun preview() = TangemThemePreview { + TangemPayReissueCardContent( + state = TangemPayReissueCardUM.stub(), + ) +} \ No newline at end of file From f32528d6535ad3307578efea8202309ff968c9b3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Apr 2026 19:31:54 +0300 Subject: [PATCH 049/206] Updated on 2026-08-14 --- .../preview/WalletBalancePreview.kt | 31 ++++++++ .../wallet/state/model/WalletBalanceUM.kt | 20 +++-- .../SetAssetsDiscoveryProgressTransformer.kt | 10 ++- .../SetTokenListErrorTransformer.kt | 1 + .../MultiWalletBalanceUMTransformer.kt | 3 + .../ui/components/common/WalletBalance.kt | 74 ++++++++++++++++--- 6 files changed, 122 insertions(+), 17 deletions(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt index 57e11b02aa..2bb705a19e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/preview/WalletBalancePreview.kt @@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.styledStringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM internal object WalletBalancePreview { @@ -38,6 +39,36 @@ internal object WalletBalancePreview { isZeroBalance = false, ) + val syncProgress = WalletBalanceUM.Content( + id = UserWalletId("0"), + name = "My Wallet", + balanceInAppBar = combinedReference( + stringReference("1,234"), + styledStringReference( + ".56", + { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ), + stringReference(" $"), + ), + balance = combinedReference( + stringReference("1,234"), + styledStringReference( + ".56", + { + TangemTheme.typography2.headingRegular28.toSpanStyle() + }, + ), + stringReference(" $"), + ), + deviceIcon = DeviceIconUM.Stub(cardsCount = 3), + isBalanceFlickering = false, + isZeroBalance = false, + additionalInfo = WalletAdditionalInfo( + hideable = false, + content = WalletAdditionalInfo.Content.SyncProgress(37), + ), + ) + val hiddenBalanceContent = content.copy(balance = content.balance.orMaskWithStars(true)) val loading: WalletBalanceUM.Loading = WalletBalanceUM.Loading( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt index cc322c0a38..428a23ab2f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletBalanceUM.kt @@ -28,6 +28,9 @@ internal sealed interface WalletBalanceUM { /** Wallet Icon */ val deviceIcon: DeviceIconUM + /** Wallet additional info (e.g. card count, sync progress) */ + val additionalInfo: WalletAdditionalInfo? + /** * Wallet card content state * @@ -39,6 +42,7 @@ internal sealed interface WalletBalanceUM { override val id: UserWalletId, override val name: String, override val deviceIcon: DeviceIconUM, + override val additionalInfo: WalletAdditionalInfo? = null, val balance: TextReference, val balanceInAppBar: TextReference, val isBalanceFlickering: Boolean, @@ -55,6 +59,7 @@ internal sealed interface WalletBalanceUM { override val id: UserWalletId, override val name: String, override val deviceIcon: DeviceIconUM, + override val additionalInfo: WalletAdditionalInfo? = null, ) : WalletBalanceUM /** @@ -67,6 +72,7 @@ internal sealed interface WalletBalanceUM { override val id: UserWalletId, override val name: String, override val deviceIcon: DeviceIconUM, + override val additionalInfo: WalletAdditionalInfo? = null, ) : WalletBalanceUM /** @@ -79,14 +85,18 @@ internal sealed interface WalletBalanceUM { override val id: UserWalletId, override val name: String, override val deviceIcon: DeviceIconUM, + override val additionalInfo: WalletAdditionalInfo? = null, ) : WalletBalanceUM - fun copySealed(name: String): WalletBalanceUM { + fun copySealed( + name: String = this.name, + additionalInfo: WalletAdditionalInfo? = this.additionalInfo, + ): WalletBalanceUM { return when (this) { - is Content -> copy(name = name) - is Error -> copy(name = name) - is Loading -> copy(name = name) - is Empty -> copy(name = name) + is Content -> copy(name = name, additionalInfo = additionalInfo) + is Error -> copy(name = name, additionalInfo = additionalInfo) + is Loading -> copy(name = name, additionalInfo = additionalInfo) + is Empty -> copy(name = name, additionalInfo = additionalInfo) } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt index e5dbe43b05..a0adf2a7e4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetAssetsDiscoveryProgressTransformer.kt @@ -22,7 +22,15 @@ internal class SetAssetsDiscoveryProgressTransformer( } } - override fun transform(walletUM: WalletUM): WalletUM = walletUM + override fun transform(walletUM: WalletUM): WalletUM { + val additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = userWallet, syncProgress = progress) + return when (walletUM) { + is WalletUM.Content -> walletUM.copy( + walletsBalanceUM = walletUM.walletsBalanceUM.copySealed(additionalInfo = additionalInfo), + ) + is WalletUM.Locked -> walletUM + } + } private fun updateCardState(cardState: WalletCardState): WalletCardState { val additionalInfo = WalletAdditionalInfoFactory.resolve(wallet = userWallet, syncProgress = progress) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt index 06d71e8d28..e8accceb0b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListErrorTransformer.kt @@ -98,6 +98,7 @@ internal class SetTokenListErrorTransformer( id = id, name = name, deviceIcon = deviceIcon, + additionalInfo = additionalInfo, balanceInAppBar = BigDecimal.ZERO.formatStyled { fiat( fiatCurrencyCode = appCurrency.code, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletBalanceUMTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletBalanceUMTransformer.kt index d3a37dbb31..9765ff1052 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletBalanceUMTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/MultiWalletBalanceUMTransformer.kt @@ -29,6 +29,7 @@ internal class MultiWalletBalanceUMTransformer( id = id, name = name, deviceIcon = deviceIcon, + additionalInfo = additionalInfo, ) } @@ -37,6 +38,7 @@ internal class MultiWalletBalanceUMTransformer( id = id, name = name, deviceIcon = deviceIcon, + additionalInfo = additionalInfo, ) } @@ -45,6 +47,7 @@ internal class MultiWalletBalanceUMTransformer( id = id, name = name, deviceIcon = deviceIcon, + additionalInfo = additionalInfo, balanceInAppBar = fiatBalance.amount.formatStyled { fiat( fiatCurrencyCode = appCurrency.code, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt index 7afd89c781..9e1eef6fba 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/common/WalletBalance.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -33,11 +34,16 @@ import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedS import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.MainScreenTestTags +import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.preview.WalletBalancePreview import com.tangem.feature.wallet.presentation.preview.WalletPreviewData +import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList @@ -79,17 +85,7 @@ internal fun WalletBalance( isBalanceHidden = isBalanceHidden, ) SpacerH(TangemTheme.dimens2.x3) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), - ) { - Text( - text = walletBalanceUM.name, - style = TangemTheme.typography2.bodyRegular14, - color = TangemTheme.colors2.text.neutral.tertiary, - ) - TangemDeviceIcon(state = walletBalanceUM.deviceIcon) - } + SubtitleRow(walletBalanceUM = walletBalanceUM) } SpacerH(TangemTheme.dimens2.x2) ActionButtons(buttons) @@ -97,6 +93,61 @@ internal fun WalletBalance( } } +@Composable +private fun SubtitleRow(walletBalanceUM: WalletBalanceUM, modifier: Modifier = Modifier) { + AnimatedContent( + targetState = walletBalanceUM.additionalInfo?.content, + contentKey = { content -> + when (content) { + is WalletAdditionalInfo.Content.SyncProgress -> WalletAdditionalInfo.Content.SyncProgress::class + else -> content + } + }, + label = "Update subtitle", + modifier = modifier, + transitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 90)) + }, + ) { content -> + when (content) { + is WalletAdditionalInfo.Content.SyncProgress -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1_5), + ) { + Text( + text = resourceReference( + id = R.string.initial_wallet_sync_restore_progress, + formatArgs = wrappedList(content.progressPercent), + ).resolveReference(), + style = TangemTheme.typography2.bodyRegular14, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + CircularProgressIndicator( + modifier = Modifier.size(19.dp), + color = TangemTheme.colors2.graphic.neutral.primary, + strokeWidth = 2.dp, + ) + } + } + else -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Text( + text = walletBalanceUM.name, + style = TangemTheme.typography2.bodyRegular14, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + TangemDeviceIcon(state = walletBalanceUM.deviceIcon) + } + } + } + } +} + @Composable private fun Balance(walletBalanceUM: WalletBalanceUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { AnimatedContent( @@ -175,6 +226,7 @@ private class WalletBalancePreviewProvider : PreviewParameterProvider get() = sequenceOf( WalletBalancePreviewData(WalletBalancePreview.content, WalletPreviewData.actionButtons), + WalletBalancePreviewData(WalletBalancePreview.syncProgress, WalletPreviewData.actionButtons), WalletBalancePreviewData(WalletBalancePreview.hiddenBalanceContent, WalletPreviewData.actionButtons), WalletBalancePreviewData(WalletBalancePreview.loading, WalletPreviewData.disabledActionButtons), WalletBalancePreviewData(WalletBalancePreview.error, WalletPreviewData.disabledActionButtons), From 7de4f3ab1f166b14b89b8dd98ea4bcc75297a947 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 8 Apr 2026 13:17:26 +0400 Subject: [PATCH 050/206] Updated on 2026-08-14 --- .../skills/cleanup-feature-toggles/SKILL.md | 320 ++++++++++++++++++ .../utils/annotations/RemoveWithToggle.kt | 32 ++ 2 files changed, 352 insertions(+) create mode 100644 .claude/skills/cleanup-feature-toggles/SKILL.md create mode 100644 core/utils/src/main/java/com/tangem/utils/annotations/RemoveWithToggle.kt diff --git a/.claude/skills/cleanup-feature-toggles/SKILL.md b/.claude/skills/cleanup-feature-toggles/SKILL.md new file mode 100644 index 0000000000..61a7873d5e --- /dev/null +++ b/.claude/skills/cleanup-feature-toggles/SKILL.md @@ -0,0 +1,320 @@ +--- +name: cleanup-feature-toggles +description: Remove released feature toggles (version <= target) — deletes from config, removes toggle properties, inlines `true` in calling code, removes dead branches. CI-safe, no prompts. +allowed-tools: Read, Grep, Glob, Bash, Edit, Write, Agent +argument-hint: [--dry-run] [--only ] +--- + +Remove all feature toggles whose version is less than or equal to the target release version. + +**CRITICAL: This skill runs on CI. NEVER ask questions. If anything is ambiguous, make the safer choice or skip the toggle.** + +## Constants + +- **Config file**: `core/config-toggles/src/main/assets/configs/feature_toggles_config.json` +- **Generated enum** (DO NOT edit): `core/config-toggles/build/generated/source/toggles/com/tangem/core/configtoggle/FeatureToggles.kt` +- **Dry-run mode**: check if `$ARGUMENTS` contains `--dry-run`. In dry-run mode, make NO file changes — only output what WOULD be removed (including affected files and usage sites). +- **Version**: extract the version number from `$ARGUMENTS` (e.g., `5.35`, `5.35.0`). The version is the first argument that matches a semver-like pattern (`X.Y` or `X.Y.Z`). +- **Only mode**: check if `$ARGUMENTS` contains `--only `. If present, process ONLY the specified toggle (it must still satisfy the version check). Multiple `--only` flags can be provided. + +## Phase 0: Preflight Checks + +### 0a. Parse Arguments + +Extract ``, optional `--dry-run`, and optional `--only ` (repeatable) from `$ARGUMENTS`. + +- If no version found: STOP with `FATAL: No version provided. Usage: /cleanup-feature-toggles [--dry-run] [--only ]` +- Validate version matches pattern `\d+\.\d+(\.\d+)?` — if not, STOP with `FATAL: Invalid version format.` +- Normalize version: if only `X.Y` is given, treat as `X.Y.0` for comparison. +- If `--only` flags are present, collect the toggle names into a filter list. + +### 0b. Verify Git State + +```bash +git status --porcelain 2>&1 +``` +- If output is empty (clean working tree) — OK. +- If there are uncommitted changes — STOP with: `FATAL: Working tree is not clean. Commit or stash changes before running this skill.` + +Initialize an internal results list to track each toggle's outcome. + +## Phase 1: Identify Toggles to Remove + +1. Read `core/config-toggles/src/main/assets/configs/feature_toggles_config.json`. +2. For each toggle entry in the JSON array: + - If `version == "undefined"` → skip (unreleased feature, must not be removed). + - Parse the toggle's version as semver (normalize `X.Y` to `X.Y.0`). + - If toggle version **<=** target version → mark for removal. +3. If `--only` filter is active: keep only toggles whose `name` matches one of the `--only` values. If a `--only` toggle doesn't satisfy the version check, output a warning but still skip it. +4. Output the list of toggles marked for removal with their versions. +5. If no toggles match → output `No toggles to remove for version ` and stop. +6. If `--dry-run` mode → proceed to Phase 2 (research only, all toggles in parallel), then skip to Phase 7 to output the detailed summary. Do NOT make any file changes. + +## Phase 2: Research (parallel) + +Collect all information about all toggles **in parallel** before making any edits. Launch one `Agent` per toggle (all in a single message so they run concurrently). Each agent receives the toggle name and must return a structured report. + +**Error handling rule**: if research fails for a toggle, record the failure reason and continue. Do NOT stop processing. + +### Per-toggle research task (runs inside each Agent) + +Each Agent performs the following read-only searches and returns a structured report: + +#### 2a. Find the toggle property declaration and direct usages + +Use `Grep` to search for `FeatureToggles.` (e.g., `FeatureToggles.WALLET_REORDER_FEATURE_ENABLED`) across the **entire** codebase. + +This will find: +1. **`DefaultXxxFeatureToggles` property** — the standard wrapper. Extract: + - The **property name** (e.g., `isWalletReorderFeatureEnabled`) + - The **DefaultXxxFeatureToggles file path** + - The **XxxFeatureToggles interface name** (from the class's supertype) +2. **Direct `FeatureTogglesManager.isFeatureEnabled()` calls** — code that bypasses the wrapper and calls the manager directly. These are additional usage sites. + +Also find the interface file: +- Use `Glob` to find the `XxxFeatureToggles.kt` file in `features/*/api/` or `core/*/` + +Check if the toggle property or the `DefaultXxxFeatureToggles` class has **comments referencing additional cleanup** (e.g., `// Remove GiveTxPermissionBottomSheet and all dependencies with this toggle`). If found, record the comment text. + +If the toggle reference is not found anywhere: report as `Skipped (no property found)`. + +#### 2b. Find `@RemoveWithToggle` annotated code + +Use `Grep` to search for `RemoveWithToggle` (without `@` or package prefix) across the entire codebase (excluding the annotation definition itself). Then filter matches to only those where the `toggleName` argument equals the current toggle name. + +The annotation is defined in `core/utils/src/main/java/com/tangem/utils/annotations/RemoveWithToggle.kt` (`com.tangem.utils.annotations.RemoveWithToggle`). It has two parameters: `toggleName: String` (the toggle name) and `description: String` (optional hint). + +Support all Kotlin annotation forms: +- `@RemoveWithToggle("TOGGLE_NAME")` +- `@RemoveWithToggle(toggleName = "TOGGLE_NAME")` +- `@com.tangem.utils.annotations.RemoveWithToggle("TOGGLE_NAME")` +- `@com.tangem.utils.annotations.RemoveWithToggle(toggleName = "TOGGLE_NAME")` + +For each filtered match, record: +- The file path and line number +- The annotated element name (class, function, property) +- The `description` value if present + +#### 2c. Find all usages of the property in calling code + +Use `Grep` to search for the property name (e.g., `isWalletReorderFeatureEnabled`) across the entire codebase. + +Categorize results: +- **Interface declaration** — the `val isX: Boolean` in `XxxFeatureToggles.kt` +- **Implementation** — the `override val isX` in `DefaultXxxFeatureToggles.kt` +- **Calling code** — any other file that reads `*.isX` (include file path, line number, and the matched line content) + +#### Agent report format + +Each Agent must return a report with: +- Toggle name +- Property name (e.g., `isWalletReorderFeatureEnabled`) or `null` if not found +- Interface name and file path +- Implementation file path +- List of calling code sites: `[{file, line, content}]` +- List of direct `FeatureTogglesManager` usage sites: `[{file, line, content}]` +- List of `@RemoveWithToggle` sites: `[{file, line, element, description}]` +- Cleanup comments (if any) +- Status: `ready` or `skipped (reason)` + +### After all Agents complete + +Collect all reports. If `--dry-run` → skip to Phase 6 with the collected data. + +## Phase 3: Edit (sequential) + +Process each toggle **sequentially** using the research data from Phase 2. Only toggles with status `ready` are processed. + +**Error handling rule**: if ANY step fails for a toggle, record the failure reason and continue to the next toggle. Do NOT stop processing. + +### Step 3a: Replace usages in calling code with `true` and simplify + +For each calling code usage site (from Phase 2 report), `Read` the surrounding context (at least 20 lines around the usage) and apply the appropriate simplification: + +| Pattern | Simplification | +|---------|---------------| +| `if (toggles.isX) { body }` | Remove `if`, keep `body` (unindent) | +| `if (toggles.isX) { A } else { B }` | Keep only `A`, remove if/else structure | +| `if (!toggles.isX) { body }` | Remove entire if-block | +| `if (!toggles.isX) { A } else { B }` | Keep only `B`, remove if/else structure | +| `toggles.isX && expr` | Replace with `expr` | +| `expr && toggles.isX` | Replace with `expr` | +| `toggles.isX \|\| expr` | Replace with `true` (or simplify enclosing condition since it's always true) | +| `val x = toggles.isX` | Replace with `val x = true`, then check if `x` is used in one of the patterns above and simplify transitively | +| `property = toggles.isX` | Replace with `property = true` | +| `when { toggles.isX -> A; else -> B }` | Keep only `A`, remove the `when` structure | +| `when { !toggles.isX -> A; else -> B }` | Keep only `B`, remove the `when` structure | +| `when(value) { ... }` with toggle in a branch condition | Evaluate the toggle to `true`, simplify the `when` accordingly | +| Complex boolean expression | Replace `toggles.isX` with `true` and algebraically simplify | + +Also process any direct `FeatureTogglesManager.isFeatureEnabled()` call sites the same way (replace with `true` and simplify). + +**After replacing**, check if the file still references the `XxxFeatureToggles` type: +- If not → remove the import of `XxxFeatureToggles` +- If the type was a constructor/inject parameter and is no longer used → remove the parameter and any `@Inject`/`@Assisted` annotations associated with it +- If removing a constructor parameter from a Decompose Model or Component, also remove it from the caller that creates the instance + +**Important**: Use `Edit` for precise changes. Read enough context to make correct edits. Do NOT accidentally delete unrelated code. + +### Step 3b: Remove the property from interface and implementation + +1. **In `XxxFeatureToggles` interface**: remove the `val isPropertyName: Boolean` line. +2. **In `DefaultXxxFeatureToggles`**: remove the `override val isPropertyName: Boolean` property (including the `get() = ...` line). +3. Check if `DefaultXxxFeatureToggles` still has other properties: + - If **yes** → done with this toggle. + - If **no** (all properties removed) → the interface and implementation are now empty. Check the **protected list** below — if the interface is protected, keep it and skip deletion. Otherwise, delete them: + +**Protected interfaces (never delete even if empty):** +- `TokensFeatureToggles` +- `BlockchainSDKFeatureToggles` +- `StakingFeatureToggles` +- `CardSdkFeatureToggles` +- `TangemPayFeatureToggles` +- `SwapFeatureToggles` +- `SendFeatureToggles` + +If the interface is **protected**: remove the `featureTogglesManager` / `featureToggles` constructor parameter from `DefaultXxxFeatureToggles`, remove unused imports (`FeatureTogglesManager`, `FeatureToggles`), but keep both files. + +If the interface is **not protected** → delete them: + 1. Delete the `XxxFeatureToggles` interface file. + 2. Delete the `DefaultXxxFeatureToggles` implementation file. + 3. Find and remove the Hilt binding for this interface (typically a `@Binds` method in a `*FeatureTogglesModule` or similar Hilt module). If the Hilt module has no remaining bindings after removal, delete the module file as well. + 4. Use `Grep` to find all remaining references to `XxxFeatureToggles` and `DefaultXxxFeatureToggles` across the codebase. For each reference: + - **Constructor/inject parameter** → remove the parameter. If the surrounding class/function no longer uses any feature toggles, cascade the removal to its callers. + - **Import statement** → remove it. + - **Any other reference** → assess and remove or update as needed. + +Record status as `Removed` with the count of usage sites simplified. + +## Phase 4: Update JSON Config + +1. Read `feature_toggles_config.json`. +2. Remove all entries whose `name` matches a successfully removed toggle (status = `Removed`). +3. Write back the JSON with proper formatting: + - 2-space indentation + - Each entry on its own lines + - No trailing commas + - Match the existing file format exactly + +## Phase 5: Build, Test & Lint Verification + +Run all verification tasks in a **single Gradle invocation** to avoid repeated cold starts: + +### 5a. Build + Tests + Detekt + +```bash +./gradlew assembleGoogleDebug unitTest detekt detektMain :app:assembleGoogleMocked :app:assembleGoogleMockedAndroidTest +``` + +- If the command **fails**: + - Read the error output to determine which task failed. + - **Compilation error** (`assembleGoogleDebug` or `assembleGoogleMocked`): attempt to fix (one retry — usually missing import removal or unused parameter). If still fails: revert all changes with `git checkout -- .` and output `FATAL: Build failed after cleanup. All changes reverted.` with the error details. + - **Unit test failure**: attempt to fix (one retry). If still fails: output the failures as warnings in the summary but do NOT revert. + - **Detekt violation**: attempt to fix (one retry — usually unused imports or parameters). If still fails: output the violations as warnings in the summary. + - **UI test compilation failure**: attempt to fix (one retry). If still fails: output the failures as warnings in the summary. + - After fixing, re-run the **full command** to verify everything passes together. + +## Phase 6: Branch, Commit, Push & PR + +Skip this phase entirely in `--dry-run` mode. + +### 6a. Create Branch and Commit + +```bash +git checkout -b tech/cleanup-toggles- +git add -A +git commit -m "[Tech] Remove feature toggles <= " +``` + +Replace `` with the target version (e.g., `tech/cleanup-toggles-5.35`). + +### 6b. Push + +```bash +git push -u origin tech/cleanup-toggles- +``` + +### 6c. Create Pull Request + +Use `gh pr create` targeting `develop`: + +```bash +gh pr create --base develop --title "Remove feature toggles <= " --body "$(cat <<'EOF' +## Summary + +Automated cleanup of feature toggles that are permanently enabled (version <= ). + +### Removed toggles + +- `TOGGLE_NAME_1` (version) +- `TOGGLE_NAME_2` (version) +- ... + +### Manual review required + + + +## Test plan + +- [x] `assembleGoogleDebug` passes +- [x] `unitTest` passes +- [x] `detekt detektMain` passes + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Output the PR URL. + +## Phase 7: Output Summary + +Output results as a Markdown table: + +```markdown +## Feature Toggle Cleanup Summary + +| Toggle | Version | Interface | Usages Simplified | Status | +|--------|---------|-----------|-------------------|--------| +| WALLET_REORDER_FEATURE_ENABLED | 5.34 | WalletFeatureToggles | 3 | Removed | +| EARN_BLOCK_ENABLED | 5.35 | EarnFeatureToggles | 1 | Removed | +| SOME_TOGGLE | 5.33 | SomeFeatureToggles | — | Skipped (no property found) | + +**Total:** X toggles processed, Y removed, Z skipped/failed +**Target version:** +``` + +### Manual Review Hints + +If any toggle had a comment referencing additional cleanup (found in Phase 2 research), output a separate section: + +```markdown +### Manual Review Required + +- **GASLESS_APPROVAL_ENABLED**: `// Remove GiveTxPermissionBottomSheet and all dependencies with this toggle` +- **OTHER_TOGGLE**: `// Also remove legacy FooBar component` +``` + +### Dry-run mode output + +In `--dry-run` mode: prepend `[DRY RUN]` to the header, set all statuses to `Would remove`, and add a detailed section per toggle: + +```markdown +### WALLET_REORDER_FEATURE_ENABLED (5.34) — Would remove + +**Property:** `WalletFeatureToggles.isWalletReorderFeatureEnabled` +**Files affected:** +- `features/details/impl/.../UserWalletListModel.kt:42` — `walletFeatureToggles.isWalletReorderFeatureEnabled && userWallets.size > 1` +- `features/wallet/impl/.../SomeOtherFile.kt:88` — `if (walletFeatureToggles.isWalletReorderFeatureEnabled)` +``` + +### Warnings + +If unit tests or detekt failed after fix attempts, list the remaining issues: + +```markdown +### Warnings + +- **Unit test failure:** `:features:wallet:impl:testDebugUnitTest` — WalletModelTest.someTest (may need manual update) +- **Detekt violation:** UnusedPrivateMember in `SomeFile.kt:15` +``` \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/annotations/RemoveWithToggle.kt b/core/utils/src/main/java/com/tangem/utils/annotations/RemoveWithToggle.kt new file mode 100644 index 0000000000..4a9f4f9468 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/annotations/RemoveWithToggle.kt @@ -0,0 +1,32 @@ +package com.tangem.utils.annotations + +/** + * Marks code that should be removed when the specified feature toggle is cleaned up + * by the `/cleanup-feature-toggles` skill. + * + * @property toggleName the name of the feature toggle (e.g., "GASLESS_APPROVAL_ENABLED") + * @property description optional description of what should be done during cleanup + * +[REDACTED_AUTHOR] + */ +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.ANNOTATION_CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FIELD, + AnnotationTarget.LOCAL_VARIABLE, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.TYPE, + AnnotationTarget.EXPRESSION, + AnnotationTarget.FILE, + AnnotationTarget.TYPEALIAS, +) +@Retention(AnnotationRetention.SOURCE) +annotation class RemoveWithToggle( + val toggleName: String, + val description: String = "", +) \ No newline at end of file From 6badbf18b5d3c219a35fd49c81c0d99fb7951c4d Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Apr 2026 11:00:18 +0200 Subject: [PATCH 051/206] Updated on 2026-08-14 --- .../src/main/res/drawable/ic_key_card_20.xml | 20 + features/feed/impl/build.gradle.kts | 12 +- .../search/DefaultSearchComponent.kt | 30 ++ .../search/SearchBottomSheetRoute.kt | 15 + .../search/SearchTokenSelectorComponent.kt | 46 +++ .../tangem/features/feed/di/ModelModule.kt | 6 + .../features/feed/model/search/SearchModel.kt | 46 ++- .../model/search/SearchTokenSelectorModel.kt | 38 ++ .../converter/UserAssetSearchItemConverter.kt | 18 +- .../state/TokenSelectorStateController.kt | 25 ++ .../BuildTokenSelectorSectionsTransformer.kt | 67 ++++ .../TokenSelectorEntryConverter.kt | 98 +++++ .../TokenSelectorUMTransformer.kt | 7 + .../features/feed/ui/search/SearchContent.kt | 9 +- .../search/components/SingleUserAssetItem.kt | 72 ++-- .../components/TokenSelectorBottomSheet.kt | 146 +++++++ .../ui/search/components/TokenSelectorList.kt | 140 +++++++ .../ui/search/preview/SearchContentPreview.kt | 1 + .../TokenSelectorContentPreviewProvider.kt | 122 ++++++ .../ui/search/preview/UserAssetItemPreview.kt | 13 +- .../features/feed/ui/search/state/SearchUM.kt | 13 +- .../feed/ui/search/state/TokenSelectorUM.kt | 29 ++ ...ildTokenSelectorSectionsTransformerTest.kt | 304 +++++++++++++++ .../TokenSelectorEntryConverterTest.kt | 358 ++++++++++++++++++ 24 files changed, 1557 insertions(+), 78 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_key_card_20.xml create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt diff --git a/core/ui/src/main/res/drawable/ic_key_card_20.xml b/core/ui/src/main/res/drawable/ic_key_card_20.xml new file mode 100644 index 0000000000..1a0274c0cd --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_key_card_20.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index f5d80f4224..7d4dc84f32 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -9,7 +9,7 @@ plugins { android { namespace = "com.tangem.features.feed.impl" - + packaging { resources { merges += "paymentrequest.proto" @@ -17,6 +17,10 @@ android { } } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /* Project - API */ api(projects.features.feed.api) @@ -102,4 +106,10 @@ dependencies { /** Tangem libraries */ implementation(tangemDeps.card.core) implementation(tangemDeps.blockchain) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt index 677f40ed5a..45392661d4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt @@ -6,10 +6,15 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.ds.field.search.TangemFieldShape import com.tangem.core.ui.ds.field.search.TangemSearchField @@ -29,6 +34,13 @@ internal class DefaultSearchComponent( private val model = getOrCreateModel(params = params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = null, + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + @Composable override fun Title(bottomSheetState: State) { val state by model.state.collectAsStateWithLifecycle() @@ -69,6 +81,7 @@ internal class DefaultSearchComponent( contentPadding: PaddingValues, modifier: Modifier, ) { + val bottomSheet by bottomSheetSlot.subscribeAsState() val state by model.state.collectAsStateWithLifecycle() val searchCallbacks = remember { SearchCallbacks( @@ -85,6 +98,23 @@ internal class DefaultSearchComponent( searchCallbacks = searchCallbacks, contentPadding = contentPadding, ) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: SearchBottomSheetRoute, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent = when (config) { + is SearchBottomSheetRoute.TokenSelector -> SearchTokenSelectorComponent( + context = childByContext(componentContext), + params = SearchTokenSelectorComponent.Params( + entries = config.entries, + appCurrency = config.appCurrency, + isBalanceHidden = config.isBalanceHidden, + onTokenSelected = config.onTokenSelected, + onDismiss = config.onDismiss, + ), + ) } data class Params( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt new file mode 100644 index 0000000000..7ac509eccf --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt @@ -0,0 +1,15 @@ +package com.tangem.features.feed.components.search + +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.search.model.UserAssetSearchEntry + +internal sealed interface SearchBottomSheetRoute { + + data class TokenSelector( + val entries: List, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val onTokenSelected: (UserAssetSearchEntry) -> Unit, + val onDismiss: () -> Unit, + ) : SearchBottomSheetRoute +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt new file mode 100644 index 0000000000..a36895267e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt @@ -0,0 +1,46 @@ +package com.tangem.features.feed.components.search + +import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.features.feed.model.search.SearchTokenSelectorModel +import com.tangem.features.feed.ui.search.components.TokenSelectorBottomSheet +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +internal class SearchTokenSelectorComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: Params, +) : AppComponentContext by context, ComposableBottomSheetComponent { + + private val model = getOrCreateModel(params = params) + + override fun dismiss() { + params.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state = model.state.collectAsStateWithLifecycle() + TokenSelectorBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::dismiss, + content = state.value, + ), + ) + } + + data class Params( + val entries: List, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val onTokenSelected: (UserAssetSearchEntry) -> Unit, + val onDismiss: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt index 72864031f8..69f12c89b5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/ModelModule.kt @@ -12,6 +12,7 @@ import com.tangem.features.feed.model.market.list.MarketsListModel import com.tangem.features.feed.model.news.details.NewsDetailsModel import com.tangem.features.feed.model.news.list.NewsListModel import com.tangem.features.feed.model.search.SearchModel +import com.tangem.features.feed.model.search.SearchTokenSelectorModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -71,4 +72,9 @@ internal interface ModelModule { @IntoMap @ClassKey(SearchModel::class) fun provideSearchModel(model: SearchModel): Model + + @Binds + @IntoMap + @ClassKey(SearchTokenSelectorModel::class) + fun provideSearchTokenSelectorModel(model: SearchTokenSelectorModel): Model } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt index c55b8f0ec8..839d5877f7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt @@ -1,6 +1,9 @@ package com.tangem.features.feed.model.search import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.ui.charts.state.MarketChartData @@ -13,28 +16,25 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase -import com.tangem.domain.markets.GetTokenPriceChartUseCase -import com.tangem.domain.markets.PriceChangeInterval -import com.tangem.domain.markets.TokenMarketParams -import com.tangem.domain.markets.toSerializableParam +import com.tangem.domain.markets.* +import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.search.model.UserAssetSearchItem import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase import com.tangem.domain.search.usecase.GetSearchResultsUseCase import com.tangem.domain.search.usecase.SaveRecentSearchTokenUseCase -import com.tangem.domain.search.model.UserAssetSearchEntry import com.tangem.domain.search.usecase.SaveSearchQueryUseCase import com.tangem.features.feed.components.search.DefaultSearchComponent +import com.tangem.features.feed.components.search.SearchBottomSheetRoute import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager -import com.tangem.features.feed.model.search.converter.MarketsListItemUMToRecentSearchTokenConverter -import com.tangem.features.feed.model.search.converter.MarketsListItemUMWithAppCurrency -import com.tangem.features.feed.model.search.converter.RecentSearchTokenToMarketsListItemUMConverter -import com.tangem.features.feed.model.search.converter.RecentSearchTokenWithAppCurrency -import com.tangem.features.feed.model.search.converter.UserAssetSearchItemConverter +import com.tangem.features.feed.model.search.converter.* import com.tangem.features.feed.model.search.state.SearchStateController import com.tangem.features.feed.model.search.state.transformers.* -import com.tangem.features.feed.ui.search.state.* +import com.tangem.features.feed.ui.search.state.MarketSearchResultUM +import com.tangem.features.feed.ui.search.state.SearchContentUM +import com.tangem.features.feed.ui.search.state.SearchUM +import com.tangem.features.feed.ui.search.state.TextHintItemUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -48,7 +48,7 @@ import javax.inject.Inject private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L private const val MARKET_SEARCH_DEBOUNCE_MS = 500L -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @ModelScoped internal class SearchModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, @@ -112,6 +112,8 @@ internal class SearchModel @Inject constructor( ) } + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val state: StateFlow get() = stateController.uiState init { @@ -208,6 +210,23 @@ internal class SearchModel @Inject constructor( ) } + private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) { + bottomSheetNavigation.activate( + SearchBottomSheetRoute.TokenSelector( + entries = grouped.entries, + appCurrency = currentAppCurrency.value, + isBalanceHidden = isBalanceHidden.value, + onTokenSelected = ::onTokenSelectedFromGroup, + onDismiss = { bottomSheetNavigation.dismiss() }, + ), + ) + } + + private fun onTokenSelectedFromGroup(entry: UserAssetSearchEntry) { + bottomSheetNavigation.dismiss() + onSingleUserAssetClick(entry) + } + private fun subscribeToQueryChanges() { stateController.uiState .map { it.searchBar.query.trim() } @@ -245,6 +264,7 @@ internal class SearchModel @Inject constructor( appCurrency = appCurrency, isBalanceHidden = balanceHidden, onSingleClick = ::onSingleUserAssetClick, + onGroupedClick = ::onGroupedUserAssetClick, ) searchResult.userAssets .map(converter::convert) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt new file mode 100644 index 0000000000..9c8a912394 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt @@ -0,0 +1,38 @@ +package com.tangem.features.feed.model.search + +import androidx.compose.runtime.Stable +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.feed.components.search.SearchTokenSelectorComponent +import com.tangem.features.feed.model.search.state.TokenSelectorStateController +import com.tangem.features.feed.model.search.state.transformers.BuildTokenSelectorSectionsTransformer +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class SearchTokenSelectorModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, + private val stateController: TokenSelectorStateController, +) : Model() { + + private val params = paramsContainer.require() + + val state: StateFlow + get() = stateController.uiState + + init { + stateController.update( + BuildTokenSelectorSectionsTransformer( + entries = params.entries, + appCurrency = params.appCurrency, + isBalanceHidden = params.isBalanceHidden, + onTokenSelected = params.onTokenSelected, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt index 6f0134ca29..86dda1eb30 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt @@ -1,6 +1,5 @@ package com.tangem.features.feed.model.search.converter -import com.tangem.common.ui.account.toUM import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter @@ -22,13 +21,13 @@ import com.tangem.features.feed.ui.search.state.UserAssetItemUM import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.orZero -import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal internal class UserAssetSearchItemConverter( private val appCurrency: AppCurrency, private val isBalanceHidden: Boolean, private val onSingleClick: (UserAssetSearchEntry) -> Unit, + private val onGroupedClick: (UserAssetSearchItem.Grouped) -> Unit, ) : Converter { override fun convert(value: UserAssetSearchItem): UserAssetItemUM { @@ -64,6 +63,7 @@ internal class UserAssetSearchItemConverter( balanceState = convertSingleBalanceState(value, currency.symbol, currency.decimals), isBalanceHidden = isBalanceHidden, onClick = { onSingleClick(entry) }, + networkName = entry.currencyStatus.currency.network.name, ) } @@ -101,15 +101,6 @@ internal class UserAssetSearchItemConverter( private fun convertGrouped(item: UserAssetSearchItem.Grouped): UserAssetItemUM.Grouped { val firstCurrency = item.entries.first().currencyStatus.currency - val children = item.entries.map { entry -> - UserAssetItemUM.GroupedChild( - walletName = entry.userWalletName, - accountName = entry.accountName.toUM(), - accountIcon = entry.accountIcon.value, - accountColor = entry.accountIcon.color, - currencyStatus = entry.currencyStatus, - ) - }.toImmutableList() val entryCurrencyStatus = item.entries.first().currencyStatus @@ -128,8 +119,7 @@ internal class UserAssetSearchItemConverter( tokensCount = item.entries.size, balanceState = convertGroupedBalanceState(item.entries, firstCurrency.symbol, firstCurrency.decimals), isBalanceHidden = isBalanceHidden, - children = children, - onClick = {}, + onClick = { onGroupedClick(item) }, ) } @@ -141,10 +131,12 @@ internal class UserAssetSearchItemConverter( val hasAnyLoading = entries.any { it.currencyStatus.value is CryptoCurrencyStatus.Loading } val hasAnyError = entries.any { it.currencyStatus.value.isError } val hasAnyAmount = entries.any { it.currencyStatus.value.amount != null } + val isAllError = entries.all { it.currencyStatus.value.isError } val balance = when { hasAnyLoading && !hasAnyAmount -> BalanceDisplayState.Loading hasAnyLoading && hasAnyAmount -> computeGroupBalanceFlickering(entries, symbol, decimals) + isAllError -> BalanceDisplayState.Unreachable hasAnyError && entries.size == 1 && !hasAnyAmount -> BalanceDisplayState.Unreachable hasAnyError && entries.size > 1 -> computeGroupBalance(entries, symbol, decimals) else -> computeGroupBalance(entries, symbol, decimals) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt new file mode 100644 index 0000000000..40effc11de --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt @@ -0,0 +1,25 @@ +package com.tangem.features.feed.model.search.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.features.feed.model.search.state.transformers.TokenSelectorUMTransformer +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class TokenSelectorStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = MutableStateFlow( + value = TokenSelectorContentUM(sections = persistentListOf()), + ) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + fun update(transformer: TokenSelectorUMTransformer) { + mutableUiState.update(function = transformer::transform) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt new file mode 100644 index 0000000000..a6c2729c0e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt @@ -0,0 +1,67 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.tangem.common.ui.account.toUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.features.feed.ui.search.state.AccountHeaderData +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM +import kotlinx.collections.immutable.toImmutableList + +internal class BuildTokenSelectorSectionsTransformer( + private val entries: List, + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenSelected: (UserAssetSearchEntry) -> Unit, +) : TokenSelectorUMTransformer { + + private val entryConverter = TokenSelectorEntryConverter( + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenSelected = onTokenSelected, + ) + + override fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM { + return TokenSelectorContentUM(sections = buildSections().toImmutableList()) + } + + private fun buildSections(): List { + val sections = mutableListOf() + val byWallet = entries.groupBy { it.userWalletId } + val shouldShowWalletHeaders = byWallet.size > 1 + + for ((_, walletEntries) in byWallet) { + if (shouldShowWalletHeaders) { + sections.add( + TokenSelectorSectionUM.WalletHeader( + walletName = walletEntries.first().userWalletName, + ), + ) + } + + val byAccount = walletEntries.groupBy { it.accountId } + val shouldShowAccountHeaders = byAccount.size > 1 + + for ((_, accountEntries) in byAccount) { + val singles = entryConverter.convertList(accountEntries).toImmutableList() + val accountHeader = if (shouldShowAccountHeaders) { + val firstEntry = accountEntries.first() + AccountHeaderData( + accountName = firstEntry.accountName.toUM().value, + cryptoPortfolioIcon = firstEntry.accountIcon, + ) + } else { + null + } + sections.add( + TokenSelectorSectionUM.TokenGroup( + accountHeader = accountHeader, + items = singles, + ), + ) + } + } + + return sections + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt new file mode 100644 index 0000000000..b4719f582e --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt @@ -0,0 +1,98 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.features.feed.ui.search.state.UserAssetItemUM +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.orZero +import java.math.BigDecimal + +internal class TokenSelectorEntryConverter( + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenSelected: (UserAssetSearchEntry) -> Unit, +) : Converter { + + private val iconConverter = CryptoCurrencyToIconStateConverter() + + override fun convert(value: UserAssetSearchEntry): UserAssetItemUM.Single { + val currency = value.currencyStatus.currency + val currencyValue = value.currencyStatus.value + + return UserAssetItemUM.Single( + id = "${value.userWalletId.stringValue}_${value.accountId.value}_${currency.id.value}", + icon = TangemIconUM.Currency( + currencyIconState = iconConverter.convert(value.currencyStatus), + ), + tokenName = currency.name, + tokenSymbol = currency.symbol, + fiatRate = currencyValue.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) }, + priceChangeState = when (currencyValue) { + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAmount, + -> PriceChangeState.Unknown + else -> PriceChangeState.Content( + type = PriceChangeType.fromBigDecimal(currencyValue.priceChange.orZero()), + valueInPercent = currencyValue.priceChange.format { percent() }, + ) + }, + balanceState = convertBalanceState(currencyValue, currency.symbol, currency.decimals), + isBalanceHidden = isBalanceHidden, + onClick = { onTokenSelected(value) }, + networkName = value.currencyStatus.currency.network.name, + ) + } + + private fun convertBalanceState( + value: CryptoCurrencyStatus.Value, + symbol: String, + decimals: Int, + ): BalanceDisplayState { + return when { + value is CryptoCurrencyStatus.Loading && value.amount != null -> + BalanceDisplayState.Flickering( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading + value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable + value.isError && value.amount != null -> + BalanceDisplayState.Stale( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + value.isError -> BalanceDisplayState.Unreachable + else -> BalanceDisplayState.Loaded( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + } + } + + private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String { + return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt new file mode 100644 index 0000000000..f364f03d07 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt @@ -0,0 +1,7 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM + +internal interface TokenSelectorUMTransformer { + fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt index 73ab0434d4..33b2589a72 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt @@ -329,7 +329,14 @@ private fun TextHintItem(hint: TextHintItemUM, onHintClick: () -> Unit) { @Composable private fun UserAssetItem(asset: UserAssetItemUM) { when (asset) { - is UserAssetItemUM.Single -> SingleUserAssetItem(item = asset) + is UserAssetItemUM.Single -> Box( + modifier = Modifier.background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + SingleUserAssetItem(item = asset, shouldUsePriceBlock = true) + } is UserAssetItemUM.Grouped -> GroupedUserAssetItem(item = asset) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt index cf255f66a5..dd388ce1c5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt @@ -1,10 +1,8 @@ package com.tangem.features.feed.ui.search.components import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -23,48 +21,52 @@ import com.tangem.features.feed.ui.search.state.BalanceDisplayState import com.tangem.features.feed.ui.search.state.UserAssetItemUM @Composable -fun SingleUserAssetItem(item: UserAssetItemUM.Single, modifier: Modifier = Modifier) { - Box( - modifier = modifier.background( - color = TangemTheme.colors2.surface.level3, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), - ), - ) { - TangemRowContainer( - modifier = Modifier.clickable(onClick = item.onClick), - content = { - TangemIcon( - modifier = Modifier - .layoutId(layoutId = TangemRowLayoutId.HEAD) - .size(40.dp) - .padding(end = TangemTheme.dimens2.x1), - tangemIconUM = item.icon, - ) +fun SingleUserAssetItem(shouldUsePriceBlock: Boolean, item: UserAssetItemUM.Single, modifier: Modifier = Modifier) { + TangemRowContainer( + modifier = modifier.clickable(onClick = item.onClick), + content = { + TangemIcon( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .size(40.dp) + .padding(end = TangemTheme.dimens2.x1), + tangemIconUM = item.icon, + ) - Text( - modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), - text = item.tokenName, - style = TangemTheme.typography2.bodyMedium16, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = item.tokenName, + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + if (shouldUsePriceBlock) { PriceBlock( modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), priceChangeState = item.priceChangeState, fiatRate = item.fiatRate, balanceState = item.balanceState, ) - - BalanceColumn( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP), - balanceState = item.balanceState, - isBalanceHidden = item.isBalanceHidden, + } else { + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = item.networkName, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, ) - }, - ) - } + } + + BalanceColumn( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP), + balanceState = item.balanceState, + isBalanceHidden = item.isBalanceHidden, + ) + }, + ) } @Composable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt new file mode 100644 index 0000000000..36196001f9 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt @@ -0,0 +1,146 @@ +package com.tangem.features.feed.ui.search.components + +import android.content.res.Configuration +import androidx.compose.animation.core.EaseOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.Fade +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.feed.ui.search.preview.TokenSelectorContentPreviewProvider +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.rememberHazeState + +@Composable +internal fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + type = TangemBottomSheetType.Modal, + containerColor = TangemTheme.colors2.surface.level2, + content = { content -> + TokenSelectorContent(content, config.onDismissRequest) + }, + ) +} + +@Composable +private fun TokenSelectorContent(content: TokenSelectorContentUM, onDismiss: () -> Unit) { + val hazeState = rememberHazeState() + var topBarHeight by remember { mutableStateOf(0.dp) } + + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + modifier = Modifier.hazeSourceTangem(state = hazeState, 1f), + contentPadding = PaddingValues( + start = TangemTheme.dimens2.x4, + end = TangemTheme.dimens2.x4, + top = topBarHeight, + bottom = TangemTheme.dimens2.x10, + ), + ) { + tokenSelectorSectionItems(content.sections) + } + TokenSelectorSheetTopBar( + modifier = Modifier.align(Alignment.TopEnd), + onDismiss = onDismiss, + hazeState = hazeState, + onChangeHeight = { topBarHeight = it }, + ) + Fade( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + height = TangemTheme.dimens2.x10, + ) + } +} + +@Composable +private fun TokenSelectorSheetTopBar( + hazeState: HazeState, + onDismiss: () -> Unit, + onChangeHeight: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + val bgColor = TangemTheme.colors2.surface.level2 + val density = LocalDensity.current + TangemTopBar( + modifier = modifier + .onGloballyPositioned { coordinates -> + if (coordinates.size.height > 0) { + with(density) { + onChangeHeight(coordinates.size.height.toDp()) + } + } + } + .hazeEffectTangem(state = hazeState) { + backgroundColor = bgColor + progressive = HazeProgressive.verticalGradient( + startIntensity = .55f, + endIntensity = 0f, + preferPerformance = true, + easing = EaseOut, + ) + }, + type = TangemTopBarType.BottomSheet, + title = resourceReference(R.string.markets_search_portfolio_header), + endContent = { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_close_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier + .size(TangemTheme.dimens2.x11) + .background( + color = TangemTheme.colors2.button.backgroundSecondary, + shape = CircleShape, + ) + .clickableSingle(onClick = onDismiss) + .padding(TangemTheme.dimens2.x2_5), + ) + }, + ) +} + +@Preview +@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TokenSelectorBottomSheetPreview( + @PreviewParameter(TokenSelectorContentPreviewProvider::class) content: TokenSelectorContentUM, +) { + TangemThemePreviewRedesign { + TokenSelectorBottomSheet( + config = TangemBottomSheetConfig( + onDismissRequest = {}, + content = content, + isShown = true, + ), + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt new file mode 100644 index 0000000000..1e96099720 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt @@ -0,0 +1,140 @@ +package com.tangem.features.feed.ui.search.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +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.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.common.ui.account.getResId +import com.tangem.common.ui.account.getUiColor +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.feed.ui.search.state.AccountHeaderData +import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM +import kotlinx.collections.immutable.ImmutableList + +internal fun LazyListScope.tokenSelectorSectionItems(sections: ImmutableList) { + sections.forEachIndexed { index, section -> + when (section) { + is TokenSelectorSectionUM.WalletHeader -> { + item(key = "wallet_${section.walletName}_$index") { + WalletHeaderSection(section) + } + } + is TokenSelectorSectionUM.TokenGroup -> { + if (section.items.isEmpty()) return@forEachIndexed + + if (index > 0 && sections[index - 1] is TokenSelectorSectionUM.TokenGroup) { + item(key = "spacer_before_group_$index") { + SpacerH(TangemTheme.dimens2.x2) + } + } + + val lastIndex = if (section.accountHeader != null) { + section.items.size + } else { + section.items.lastIndex.coerceAtLeast(0) + } + + section.accountHeader?.let { header -> + item(key = "account_header_$index") { + TokenGroupAccountHeaderRow( + data = header, + modifier = Modifier.tokenGroupRowDecoration( + currentIndex = 0, + lastIndex = lastIndex, + ), + ) + } + } + + val indexOffset = if (section.accountHeader != null) 1 else 0 + section.items.forEachIndexed { itemIndex, single -> + item(key = "token_${single.id}_$index") { + SingleUserAssetItem( + item = single, + modifier = Modifier.tokenGroupRowDecoration( + currentIndex = indexOffset + itemIndex, + lastIndex = lastIndex, + ), + shouldUsePriceBlock = false, + ) + } + } + } + } + } +} + +@Composable +private fun Modifier.tokenGroupRowDecoration(currentIndex: Int, lastIndex: Int): Modifier = + this.roundedShapeItemDecoration( + currentIndex = currentIndex, + lastIndex = lastIndex, + addDefaultPadding = false, + radius = TangemTheme.dimens2.x6, + backgroundColor = TangemTheme.colors2.surface.level3, + ) + +@Composable +private fun WalletHeaderSection(section: TokenSelectorSectionUM.WalletHeader) { + Row( + modifier = Modifier + .padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2) + .padding(horizontal = TangemTheme.dimens2.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Text( + text = section.walletName, + style = TangemTheme.typography2.subheadlineMedium14, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_key_card_20), + modifier = Modifier.size(TangemTheme.dimens2.x5), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + ) + } +} + +@Composable +private fun TokenGroupAccountHeaderRow(data: AccountHeaderData, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2) + .padding(horizontal = TangemTheme.dimens2.x4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x4), + imageVector = ImageVector.vectorResource(data.cryptoPortfolioIcon.value.getResId()), + tint = data.cryptoPortfolioIcon.color.getUiColor(), + contentDescription = null, + ) + Text( + text = data.accountName.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt index 58334e7bce..298dbfbcf1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt @@ -228,6 +228,7 @@ internal object SearchContentPreviewFixtures { ), isBalanceHidden = false, onClick = {}, + networkName = "Ethereum", ) private fun textHint(text: String): TextHintItemUM = TextHintItemUM(text = text) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt new file mode 100644 index 0000000000..2a1922d067 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt @@ -0,0 +1,122 @@ +package com.tangem.features.feed.ui.search.preview + +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.feed.ui.search.state.AccountHeaderData +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM +import com.tangem.features.feed.ui.search.state.UserAssetItemUM +import kotlinx.collections.immutable.persistentListOf + +@Suppress("StringLiteralDuplication") +internal class TokenSelectorContentPreviewProvider : + CollectionPreviewParameterProvider( + listOf( + tokenSelectorPreviewSimple(), + tokenSelectorPreviewWithAccountHeaders(), + tokenSelectorPreviewMultiWallet(), + ), + ) + +private fun tokenSelectorPreviewSimple(): TokenSelectorContentUM { + return TokenSelectorContentUM( + sections = persistentListOf( + TokenSelectorSectionUM.TokenGroup( + accountHeader = null, + items = persistentListOf( + previewTokenItem(id = "eth", name = "Ethereum", symbol = "ETH"), + previewTokenItem(id = "btc", name = "Bitcoin", symbol = "BTC"), + ), + ), + ), + ) +} + +private fun tokenSelectorPreviewWithAccountHeaders(): TokenSelectorContentUM { + val accountIcon = CryptoPortfolioIcon.ofCustomAccount( + value = CryptoPortfolioIcon.Icon.Wallet, + color = CryptoPortfolioIcon.Color.CaribbeanBlue, + ) + return TokenSelectorContentUM( + sections = persistentListOf( + TokenSelectorSectionUM.TokenGroup( + accountHeader = AccountHeaderData( + accountName = stringReference(value = "Main account"), + cryptoPortfolioIcon = accountIcon, + ), + items = persistentListOf( + previewTokenItem(id = "eth_main", name = "Ethereum", symbol = "ETH"), + ), + ), + TokenSelectorSectionUM.TokenGroup( + accountHeader = AccountHeaderData( + accountName = stringReference(value = "Trading"), + cryptoPortfolioIcon = accountIcon, + ), + items = persistentListOf( + previewTokenItem(id = "sol_trade", name = "Solana", symbol = "SOL"), + previewTokenItem(id = "avax_trade", name = "Avalanche", symbol = "AVAX"), + ), + ), + ), + ) +} + +private fun tokenSelectorPreviewMultiWallet(): TokenSelectorContentUM { + return TokenSelectorContentUM( + sections = persistentListOf( + TokenSelectorSectionUM.WalletHeader(walletName = "Cold wallet"), + TokenSelectorSectionUM.TokenGroup( + accountHeader = null, + items = persistentListOf( + previewTokenItem(id = "btc_cold", name = "Bitcoin", symbol = "BTC"), + ), + ), + TokenSelectorSectionUM.WalletHeader(walletName = "Hot wallet"), + TokenSelectorSectionUM.TokenGroup( + accountHeader = null, + items = persistentListOf( + previewTokenItem(id = "eth_hot", name = "Ethereum", symbol = "ETH"), + previewTokenItem(id = "usdt_hot", name = "Tether", symbol = "USDT"), + ), + ), + ), + ) +} + +private fun previewTokenItem(id: String, name: String, symbol: String): UserAssetItemUM.Single { + val cryptoRef = stringReference(value = "1.234 $symbol") + val fiatRef = stringReference(value = "$1,234.56") + return UserAssetItemUM.Single( + id = id, + icon = TangemIconUM.Currency( + CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.ic_ethereumpow_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), + tokenName = name, + tokenSymbol = symbol, + fiatRate = "$98,765.43", + priceChangeState = PriceChangeState.Content( + valueInPercent = "+2.34%", + type = PriceChangeType.UP, + ), + balanceState = BalanceDisplayState.Loaded( + cryptoBalance = cryptoRef, + fiatBalance = fiatRef, + ), + isBalanceHidden = false, + onClick = {}, + networkName = "Ethereum", + ) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt index f2f8302d45..2c9aecee9d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -22,7 +23,6 @@ import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem import com.tangem.features.feed.ui.search.components.SingleUserAssetItem import com.tangem.features.feed.ui.search.state.BalanceDisplayState import com.tangem.features.feed.ui.search.state.UserAssetItemUM -import kotlinx.collections.immutable.persistentListOf /** Labeled UI state for [SingleUserAssetItem] previews (dropdown label in Studio). */ internal data class SingleUserAssetItemPreviewScenario( @@ -146,6 +146,7 @@ internal object UserAssetItemPreviewFixtures { balanceState = balanceState, isBalanceHidden = isBalanceHidden, onClick = {}, + networkName = "Ethereum", ) private fun grouped(balanceState: BalanceDisplayState, isBalanceHidden: Boolean): UserAssetItemUM.Grouped = @@ -157,7 +158,6 @@ internal object UserAssetItemPreviewFixtures { tokensCount = 3, balanceState = balanceState, isBalanceHidden = isBalanceHidden, - children = persistentListOf(), onClick = {}, ) } @@ -185,7 +185,14 @@ private fun SingleUserAssetItemPreviewHost( .background(TangemTheme.colors2.surface.level1) .padding(8.dp), ) { - SingleUserAssetItem(item = scenario.item) + Box( + modifier = Modifier.background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + SingleUserAssetItem(item = scenario.item, shouldUsePriceBlock = true) + } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt index 31277562c6..595a24e12b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt @@ -1,14 +1,11 @@ package com.tangem.features.feed.ui.search.state import androidx.compose.runtime.Immutable -import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.domain.models.currency.CryptoCurrencyStatus import kotlinx.collections.immutable.ImmutableList data class SearchUM( @@ -87,6 +84,7 @@ sealed interface UserAssetItemUM { val priceChangeState: PriceChangeState, val balanceState: BalanceDisplayState, val isBalanceHidden: Boolean, + val networkName: String, override val onClick: () -> Unit, ) : UserAssetItemUM @@ -98,15 +96,6 @@ sealed interface UserAssetItemUM { val tokensCount: Int, val balanceState: BalanceDisplayState, val isBalanceHidden: Boolean, - val children: ImmutableList, override val onClick: () -> Unit, ) : UserAssetItemUM - - data class GroupedChild( - val walletName: String, - val accountName: AccountNameUM, - val accountIcon: CryptoPortfolioIcon.Icon, - val accountColor: CryptoPortfolioIcon.Color, - val currencyStatus: CryptoCurrencyStatus, - ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt new file mode 100644 index 0000000000..3268d7d2c8 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt @@ -0,0 +1,29 @@ +package com.tangem.features.feed.ui.search.state + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class TokenSelectorContentUM( + val sections: ImmutableList, +) : TangemBottomSheetConfigContent + +@Immutable +internal data class AccountHeaderData( + val accountName: TextReference, + val cryptoPortfolioIcon: CryptoPortfolioIcon, +) + +@Immutable +internal sealed interface TokenSelectorSectionUM { + + data class WalletHeader(val walletName: String) : TokenSelectorSectionUM + + data class TokenGroup( + val accountHeader: AccountHeaderData?, + val items: ImmutableList, + ) : TokenSelectorSectionUM +} \ No newline at end of file diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt new file mode 100644 index 0000000000..5d588ab0d3 --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt @@ -0,0 +1,304 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.google.common.truth.Truth +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.AccountName +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.wallet.UserWalletId +import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BuildTokenSelectorSectionsTransformerTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true) + private val prevState = TokenSelectorContentUM(sections = persistentListOf()) + + @BeforeEach + fun setup() { + clearMocks(onTokenSelected) + } + + @Test + fun `should return empty sections when entries list is empty`() { + val transformer = createTransformer(entries = emptyList()) + + val result = transformer.transform(prevState) + + Truth.assertThat(result.sections).isEmpty() + } + + @Test + fun `should create single TokenGroup without wallet header for single wallet`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId, "Wallet 1", accountId, "eth", "Ethereum", "ETH"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + Truth.assertThat(result.sections).hasSize(1) + Truth.assertThat(result.sections[0]).isInstanceOf(TokenSelectorSectionUM.TokenGroup::class.java) + + val group = result.sections[0] as TokenSelectorSectionUM.TokenGroup + Truth.assertThat(group.items).hasSize(2) + Truth.assertThat(group.accountHeader).isNull() + } + + @Test + fun `should add wallet headers when multiple wallets present`() { + val walletId1 = createMockUserWalletId("wallet1") + val walletId2 = createMockUserWalletId("wallet2") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId1, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId2, "Wallet 2", accountId, "eth", "Ethereum", "ETH"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val walletHeaders = result.sections.filterIsInstance() + val tokenGroups = result.sections.filterIsInstance() + + Truth.assertThat(walletHeaders).hasSize(2) + Truth.assertThat(walletHeaders[0].walletName).isEqualTo("Wallet 1") + Truth.assertThat(walletHeaders[1].walletName).isEqualTo("Wallet 2") + Truth.assertThat(tokenGroups).hasSize(2) + } + + @Test + fun `should not show account headers when single account per wallet`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "Wallet 1", accountId, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId, "Wallet 1", accountId, "eth", "Ethereum", "ETH"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(1) + Truth.assertThat(groups[0].accountHeader).isNull() + } + + @Test + fun `should show account headers when multiple accounts in same wallet`() { + val walletId = createMockUserWalletId("wallet1") + val accountId1 = createMockAccountId("account1") + val accountId2 = createMockAccountId("account2") + val entries = listOf( + createMockEntry(walletId, "Wallet 1", accountId1, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId, "Wallet 1", accountId2, "eth", "Ethereum", "ETH"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(2) + Truth.assertThat(groups[0].accountHeader).isNotNull() + Truth.assertThat(groups[1].accountHeader).isNotNull() + } + + @Test + fun `should group entries by wallet and then by account`() { + val walletId1 = createMockUserWalletId("wallet1") + val walletId2 = createMockUserWalletId("wallet2") + val account1 = createMockAccountId("acc1") + val account2 = createMockAccountId("acc2") + + val entries = listOf( + createMockEntry(walletId1, "W1", account1, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId1, "W1", account2, "eth", "Ethereum", "ETH"), + createMockEntry(walletId2, "W2", account1, "sol", "Solana", "SOL"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + // 2 wallet headers + 2 token groups for wallet1 (2 accounts) + 1 token group for wallet2 + val walletHeaders = result.sections.filterIsInstance() + val tokenGroups = result.sections.filterIsInstance() + + Truth.assertThat(walletHeaders).hasSize(2) + Truth.assertThat(tokenGroups).hasSize(3) + + // Wallet1 has 2 accounts so headers should be present + Truth.assertThat(tokenGroups[0].accountHeader).isNotNull() + Truth.assertThat(tokenGroups[1].accountHeader).isNotNull() + // Wallet2 has 1 account so no account header + Truth.assertThat(tokenGroups[2].accountHeader).isNull() + } + + @Test + fun `should correctly place multiple tokens in same account group`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId, "W1", accountId, "eth", "Ethereum", "ETH"), + createMockEntry(walletId, "W1", accountId, "sol", "Solana", "SOL"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(1) + Truth.assertThat(groups[0].items).hasSize(3) + } + + @Test + fun `should preserve order of wallets and accounts`() { + val walletId1 = createMockUserWalletId("wallet1") + val walletId2 = createMockUserWalletId("wallet2") + val account1 = createMockAccountId("acc1") + val account2 = createMockAccountId("acc2") + + val entries = listOf( + createMockEntry(walletId1, "First Wallet", account1, "btc", "Bitcoin", "BTC"), + createMockEntry(walletId1, "First Wallet", account2, "eth", "Ethereum", "ETH"), + createMockEntry(walletId2, "Second Wallet", account1, "sol", "Solana", "SOL"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val headers = result.sections.filterIsInstance() + Truth.assertThat(headers[0].walletName).isEqualTo("First Wallet") + Truth.assertThat(headers[1].walletName).isEqualTo("Second Wallet") + } + + @Test + fun `should convert entries to UserAssetItemUM Single`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevState) + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(1) + + val item = groups[0].items[0] + Truth.assertThat(item.tokenName).isEqualTo("Bitcoin") + Truth.assertThat(item.tokenSymbol).isEqualTo("BTC") + } + + @Test + fun `should ignore previous state and build from scratch`() { + val walletId = createMockUserWalletId("wallet1") + val accountId = createMockAccountId("account1") + val entries = listOf( + createMockEntry(walletId, "W1", accountId, "btc", "Bitcoin", "BTC"), + ) + + val prevStateWithSections = TokenSelectorContentUM( + sections = persistentListOf( + TokenSelectorSectionUM.WalletHeader(walletName = "Old Wallet"), + ), + ) + + val transformer = createTransformer(entries) + val result = transformer.transform(prevStateWithSections) + + val headers = result.sections.filterIsInstance() + Truth.assertThat(headers).isEmpty() + + val groups = result.sections.filterIsInstance() + Truth.assertThat(groups).hasSize(1) + } + + // region Helpers + + private fun createTransformer(entries: List): BuildTokenSelectorSectionsTransformer { + return BuildTokenSelectorSectionsTransformer( + entries = entries, + appCurrency = appCurrency, + isBalanceHidden = false, + onTokenSelected = onTokenSelected, + ) + } + + private fun createMockUserWalletId(id: String): UserWalletId { + return mockk { + every { stringValue } returns id + } + } + + private fun createMockAccountId(id: String): AccountId { + return mockk { + every { value } returns id + } + } + + private fun createMockEntry( + walletId: UserWalletId = createMockUserWalletId("wallet1"), + walletName: String = "Wallet 1", + accountId: AccountId = createMockAccountId("account1"), + currencyId: String = "btc", + currencyName: String = "Bitcoin", + currencySymbol: String = "BTC", + ): UserAssetSearchEntry { + val network = mockk(relaxed = true) { + every { name } returns "Network" + } + val currencyIdObj = mockk { + every { value } returns currencyId + } + val currency = mockk { + every { id } returns currencyIdObj + every { name } returns currencyName + every { symbol } returns currencySymbol + every { this@mockk.network } returns network + every { decimals } returns 8 + every { iconUrl } returns null + every { isCustom } returns false + } + val currencyStatus = mockk { + every { this@mockk.currency } returns currency + every { value } returns CryptoCurrencyStatus.Loaded( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + } + return mockk { + every { userWalletId } returns walletId + every { userWalletName } returns walletName + every { this@mockk.accountId } returns accountId + every { accountName } returns AccountName.DefaultMain + every { accountIcon } returns mockk(relaxed = true) + every { this@mockk.currencyStatus } returns currencyStatus + } + } + + // endregion +} \ No newline at end of file diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt new file mode 100644 index 0000000000..9663191563 --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt @@ -0,0 +1,358 @@ +package com.tangem.features.feed.model.search.state.transformers + +import com.google.common.truth.Truth +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.domain.appcurrency.model.AppCurrency +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.wallet.UserWalletId +import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TokenSelectorEntryConverterTest { + + private val appCurrency: AppCurrency = AppCurrency.Default + private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true) + + private lateinit var converter: TokenSelectorEntryConverter + + @BeforeEach + fun setup() { + clearMocks(onTokenSelected) + converter = TokenSelectorEntryConverter( + appCurrency = appCurrency, + isBalanceHidden = false, + onTokenSelected = onTokenSelected, + ) + } + + @Test + fun `should convert entry with Loaded status to Single with Loaded balance`() { + val entry = createMockEntry( + walletId = "wallet1", + accountId = "account1", + currencyId = "btc", + currencyName = "Bitcoin", + currencySymbol = "BTC", + networkName = "Bitcoin", + decimals = 8, + value = createLoadedValue( + amount = BigDecimal("1.5"), + fiatAmount = BigDecimal("45000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("2.5"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.id).isEqualTo("wallet1_account1_btc") + Truth.assertThat(result.tokenName).isEqualTo("Bitcoin") + Truth.assertThat(result.tokenSymbol).isEqualTo("BTC") + Truth.assertThat(result.networkName).isEqualTo("Bitcoin") + Truth.assertThat(result.isBalanceHidden).isFalse() + Truth.assertThat(result.balanceState).isInstanceOf(BalanceDisplayState.Loaded::class.java) + } + + @Test + fun `should set balance hidden when isBalanceHidden is true`() { + converter = TokenSelectorEntryConverter( + appCurrency = appCurrency, + isBalanceHidden = true, + onTokenSelected = onTokenSelected, + ) + + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.isBalanceHidden).isTrue() + } + + @Test + fun `should return Loading balance state for Loading value without amount`() { + val entry = createMockEntry( + value = CryptoCurrencyStatus.Loading, + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.balanceState).isEqualTo(BalanceDisplayState.Loading) + } + + @Test + fun `should return Unreachable balance state for Unreachable value without amount`() { + val entry = createMockEntry( + value = createUnreachableValue(), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.balanceState).isEqualTo(BalanceDisplayState.Unreachable) + } + + @Test + fun `should return Unknown price change state for Loading value`() { + val entry = createMockEntry( + value = CryptoCurrencyStatus.Loading, + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown) + } + + @Test + fun `should return Unknown price change state for Unreachable value`() { + val entry = createMockEntry( + value = createUnreachableValue(), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown) + } + + @Test + fun `should return Unknown price change state for MissedDerivation value`() { + val entry = createMockEntry( + value = createMissedDerivationValue(), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown) + } + + @Test + fun `should return Unknown price change state for NoAmount value`() { + val entry = createMockEntry( + value = createNoAmountValue(), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isEqualTo(PriceChangeState.Unknown) + } + + @Test + fun `should return Content price change state with UP type for positive change`() { + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("5.0"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isInstanceOf(PriceChangeState.Content::class.java) + val content = result.priceChangeState as PriceChangeState.Content + Truth.assertThat(content.type).isEqualTo(PriceChangeType.UP) + } + + @Test + fun `should return Content price change state with DOWN type for negative change`() { + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("-3.0"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.priceChangeState).isInstanceOf(PriceChangeState.Content::class.java) + val content = result.priceChangeState as PriceChangeState.Content + Truth.assertThat(content.type).isEqualTo(PriceChangeType.DOWN) + } + + @Test + fun `should invoke onTokenSelected callback when onClick is called`() { + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + ), + ) + + val result = converter.convert(entry) + result.onClick() + + verify(exactly = 1) { onTokenSelected(entry) } + } + + @Test + fun `should generate correct composite id from wallet account and currency`() { + val entry = createMockEntry( + walletId = "myWallet", + accountId = "myAccount", + currencyId = "eth", + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.id).isEqualTo("myWallet_myAccount_eth") + } + + @Test + fun `should convert list of entries`() { + val entries = listOf( + createMockEntry(currencyId = "btc", currencyName = "Bitcoin", currencySymbol = "BTC"), + createMockEntry(currencyId = "eth", currencyName = "Ethereum", currencySymbol = "ETH"), + ) + + val results = converter.convertList(entries) + + Truth.assertThat(results).hasSize(2) + Truth.assertThat(results[0].tokenName).isEqualTo("Bitcoin") + Truth.assertThat(results[1].tokenName).isEqualTo("Ethereum") + } + + @Test + fun `should return fiatRate formatted string for Loaded value`() { + val entry = createMockEntry( + value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + ), + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.fiatRate).isNotNull() + } + + @Test + fun `should return null fiatRate when value has no fiatRate`() { + val entry = createMockEntry( + value = CryptoCurrencyStatus.Loading, + ) + + val result = converter.convert(entry) + + Truth.assertThat(result.fiatRate).isNull() + } + + // region Helpers + + private fun createMockEntry( + walletId: String = "wallet1", + accountId: String = "account1", + currencyId: String = "btc", + currencyName: String = "Bitcoin", + currencySymbol: String = "BTC", + networkName: String = "Bitcoin", + decimals: Int = 8, + value: CryptoCurrencyStatus.Value = createLoadedValue( + amount = BigDecimal("1.0"), + fiatAmount = BigDecimal("30000.0"), + fiatRate = BigDecimal("30000.0"), + priceChange = BigDecimal("1.0"), + ), + ): UserAssetSearchEntry { + val userWalletId = mockk { + every { stringValue } returns walletId + } + val accountIdMock = mockk { + every { this@mockk.value } returns accountId + } + val network = mockk(relaxed = true) { + every { name } returns networkName + } + val currencyIdObj = mockk { + every { this@mockk.value } returns currencyId + } + val currency = mockk { + every { id } returns currencyIdObj + every { name } returns currencyName + every { symbol } returns currencySymbol + every { this@mockk.network } returns network + every { this@mockk.decimals } returns decimals + every { iconUrl } returns null + every { isCustom } returns false + } + val currencyStatus = mockk { + every { this@mockk.currency } returns currency + every { this@mockk.value } returns value + } + return mockk { + every { this@mockk.userWalletId } returns userWalletId + every { this@mockk.userWalletName } returns "Wallet" + every { this@mockk.accountId } returns accountIdMock + every { this@mockk.accountName } returns mockk(relaxed = true) + every { this@mockk.accountIcon } returns mockk(relaxed = true) + every { this@mockk.currencyStatus } returns currencyStatus + } + } + + private fun createLoadedValue( + amount: BigDecimal, + fiatAmount: BigDecimal, + fiatRate: BigDecimal, + priceChange: BigDecimal, + ): CryptoCurrencyStatus.Loaded { + return CryptoCurrencyStatus.Loaded( + amount = amount, + fiatAmount = fiatAmount, + fiatRate = fiatRate, + priceChange = priceChange, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = mockk(relaxed = true), + sources = CryptoCurrencyStatus.Sources(), + ) + } + + private fun createUnreachableValue(): CryptoCurrencyStatus.Unreachable { + return CryptoCurrencyStatus.Unreachable( + priceChange = null, + fiatRate = null, + networkAddress = null, + ) + } + + private fun createMissedDerivationValue(): CryptoCurrencyStatus.MissedDerivation { + return CryptoCurrencyStatus.MissedDerivation( + priceChange = null, + fiatRate = null, + ) + } + + private fun createNoAmountValue(): CryptoCurrencyStatus.NoAmount { + return CryptoCurrencyStatus.NoAmount( + priceChange = null, + fiatRate = null, + ) + } + + // endregion +} \ No newline at end of file From 70aa023245182f98cf7fe7bad88eabead6181e13 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Apr 2026 13:46:34 +0400 Subject: [PATCH 052/206] Updated on 2026-08-14 --- .../common/ui/tokens/TokenConverterParams.kt | 2 +- .../feature/swap/DefaultSwapComponent.kt | 6 - .../swap/choosetoken/api/ChooseTokenBridge.kt | 126 ++++++++++++++++++ .../choosetoken/api/ChooseTokenComponent.kt | 98 -------------- .../impl/DefaultChooseTokenBridge.kt | 50 ++++++- .../converter/ChooseTokenListItemConverter.kt | 38 ++++-- .../impl/model/ChooseTokenModel.kt | 47 ++----- .../impl/model/MarketBlockDelegate.kt | 16 ++- .../impl/model/PortfolioListBlockDelegate.kt | 55 ++++++-- .../choosetoken/impl/ui/ChooseTokenScreen.kt | 3 +- .../tangem/feature/swap/model/SwapModel.kt | 11 +- .../swap/models/SwapSelectTokenStateHolder.kt | 2 +- 12 files changed, 270 insertions(+), 184 deletions(-) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenConverterParams.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenConverterParams.kt index e6541f20cb..be987dde2a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenConverterParams.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenConverterParams.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.tokenlist.TokenList sealed interface TokenConverterParams { /** Wallet mode; list of tokens for main account */ data class Wallet( - val mainAccount: AccountStatus, + val mainAccount: AccountStatus.CryptoPortfolio, val tokenList: TokenList, ) : TokenConverterParams diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 27f301ffb3..09d6310350 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -12,7 +12,6 @@ import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import com.arkivanov.essenty.lifecycle.subscribe import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState -import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext @@ -22,7 +21,6 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.model.SwapModel @@ -56,10 +54,6 @@ internal class DefaultSwapComponent @AssistedInject constructor( context = child("chooseTokenComponent"), params = ChooseTokenComponent.Params( bridge = model.chooseTokenBridge, - settings = ChooseTokenComponent.Settings.SwapTo, - analyticsPayload = setOf( - ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value), - ), ), ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt new file mode 100644 index 0000000000..9035ff84e8 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt @@ -0,0 +1,126 @@ +package com.tangem.feature.swap.choosetoken.api + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup +import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.feature.swap.presentation.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +// todo swap move to common-features module +interface ChooseTokenBridge : ChooseTokenBridgeLegacy, ChooseTokenBridgeInternal { + + val onCurrencyChosen: Channel + val onClose: Channel + + /** + * for some Feature specific tokens filtering + */ + val tokenFilter: MutableStateFlow<(AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean> + + data class Settings( + val title: TextReference, + val isShowMarketBlock: Boolean, + ) { + companion object { + val SwapFrom = Settings( + title = resourceReference(R.string.swapping_from_title), + isShowMarketBlock = false, + ) + val SwapTo = Settings( + title = resourceReference(R.string.swapping_to_title), + isShowMarketBlock = true, + ) + } + } + + interface Factory { + fun create( + modelScope: CoroutineScope, + settings: Settings, + analyticsPayload: Set = emptySet(), + ): ChooseTokenBridge + } +} + +/** + * primary for internal impl usage, but you can also use it externally + */ +interface ChooseTokenBridgeInternal { + val settings: ChooseTokenBridge.Settings + val analyticsPayload: Set + val searchQueryState: StateFlow + val portfolioListBlock: Flow> + + fun onSearchQuery(query: SearchQuery) + fun onSearchQuery(query: String) = onSearchQuery(SearchQuery(query)) + + fun onClose() + fun onCurrencyChosen(result: ChooseTokenResult) + + @JvmInline + value class SearchQuery(val value: String) { + companion object { + val Empty = SearchQuery("") + val SearchQuery.isSearchingState: Boolean get() = this.value.isNotBlank() + val StateFlow.isSearchingState: Boolean get() = this.value.isSearchingState + } + } +} + +// todo swap legacy api, remove +interface ChooseTokenBridgeLegacy : ChooseTokenBridgeInternal { + + val onTokenSelected: Channel + val onNewTokenAdded: Channel> + + val currenciesGroup: Flow + + fun onTokenSelected(result: ChooseTokenResultOld) { + onTokenSelected.trySend(result) + onSearchQuery(SearchQuery.Empty) + } + + fun onNewTokenAdded(addedToken: Pair) { + onNewTokenAdded.trySend(addedToken) + onSearchQuery(SearchQuery.Empty) + } + + fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) +} + +data class ChooseTokenResultOld( + val cryptoCurrencyStatus: CryptoCurrencyStatus, + val account: Account, + val isSearched: Boolean, +) + +data class ChooseTokenResult( + val currency: CryptoCurrencyStatus, + val account: AccountStatus, + val wallet: UserWallet, + val analyticsPayload: Set = emptySet(), +) { + val walletId get() = wallet.walletId +} + +sealed interface ChooseTokenAnalyticsPayload { + + @Suppress("BooleanPropertyNaming") + @JvmInline + value class IsSearched(val value: Boolean) : ChooseTokenAnalyticsPayload + + @JvmInline + value class ScreensSources(val value: String) : ChooseTokenAnalyticsPayload +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt index c421ec5874..36bd0585db 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt @@ -2,110 +2,12 @@ package com.tangem.feature.swap.choosetoken.api import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup -import com.tangem.feature.swap.presentation.R -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.StateFlow - -// todo swap make universal, encapsulate, move to some common module -internal interface ChooseTokenBridge { - - // todo swap new api - val onCurrencyChosen: Channel - val onClose: Channel - - // todo swap legacy api, remove - val onTokenSelected: Channel - val onNewTokenAdded: Channel> - - val searchQueryState: StateFlow - val currenciesGroup: Flow - - fun onTokenSelected(result: ChooseTokenResultOld) { - onTokenSelected.trySend(result) - onSearchQuery("") - } - - fun onNewTokenAdded(addedToken: Pair) { - onNewTokenAdded.trySend(addedToken) - onSearchQuery("") - } - - fun onSearchQuery(query: String) - - fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) - - fun onCurrencyChosen(result: ChooseTokenResult) { - onCurrencyChosen.trySend(result) - } - - fun onClose() { - onClose.trySend(Unit) - onSearchQuery("") - } - - interface Factory { - fun create(modelScope: CoroutineScope): ChooseTokenBridge - } -} - -data class ChooseTokenResultOld( - val cryptoCurrencyStatus: CryptoCurrencyStatus, - val account: Account, - val isSearched: Boolean, -) - -data class ChooseTokenResult( - val currency: CryptoCurrencyStatus, - val account: AccountStatus, - val wallet: UserWallet, - val analyticsPayload: Set = emptySet(), -) { - val walletId get() = wallet.walletId -} - -sealed interface ChooseTokenAnalyticsPayload { - - @Suppress("BooleanPropertyNaming") - @JvmInline - value class IsSearched(val value: Boolean) : ChooseTokenAnalyticsPayload - - @JvmInline - value class ScreensSources(val value: String) : ChooseTokenAnalyticsPayload -} internal interface ChooseTokenComponent : ComposableContentComponent { data class Params( val bridge: ChooseTokenBridge, - val settings: Settings, - val analyticsPayload: Set = emptySet(), ) - data class Settings( - val title: TextReference, - val isShowMarketBlock: Boolean, - ) { - companion object { - val SwapFrom = Settings( - title = resourceReference(R.string.swapping_from_title), - isShowMarketBlock = false, - ) - val SwapTo = Settings( - title = resourceReference(R.string.swapping_to_title), - isShowMarketBlock = true, - ) - } - } - interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt index a8b7cb6142..3b3b7e6611 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt @@ -1,12 +1,19 @@ package com.tangem.feature.swap.choosetoken.impl +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge.Settings +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult import com.tangem.feature.swap.choosetoken.api.ChooseTokenResultOld import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY +import com.tangem.feature.swap.choosetoken.impl.model.PortfolioListBlockDelegate import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup +import com.tangem.feature.swap.models.TokenListUMData import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -16,6 +23,9 @@ import kotlinx.coroutines.flow.* internal class DefaultChooseTokenBridge @AssistedInject constructor( @Assisted private val modelScope: CoroutineScope, + portfolioListBlockDelegateFactory: PortfolioListBlockDelegate.Factory, + @Assisted override val settings: Settings, + @Assisted override val analyticsPayload: Set, ) : ChooseTokenBridge { override val onCurrencyChosen: Channel = Channel() @@ -24,24 +34,54 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( override val onNewTokenAdded: Channel> = Channel() override val onClose: Channel = Channel() - private val onSearchQuery: Channel = Channel() - override val searchQueryState: StateFlow = onSearchQuery.receiveAsFlow() + private val onSearchQuery: Channel = Channel() + override val searchQueryState: StateFlow = onSearchQuery.receiveAsFlow() .debounce(DEBOUNCE_SEARCH_DELAY) - .stateIn(modelScope, SharingStarted.Eagerly, initialValue = "") + .stateIn(modelScope, SharingStarted.Eagerly, initialValue = SearchQuery.Empty) + + private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create( + modelScope = modelScope, + searchQueryState = searchQueryState, + ) + + override val tokenFilter: MutableStateFlow<(AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean> + get() = portfolioListBlockDelegate.tokenFilter + override val portfolioListBlock: Flow> + get() = portfolioListBlockDelegate.portfolioList private val _currenciesGroupFlow = MutableStateFlow(null) override val currenciesGroup: Flow = _currenciesGroupFlow.filterNotNull() - override fun onSearchQuery(query: String) { + init { + portfolioListBlockDelegate.onTokenChosen.receiveAsFlow() + .onEach { chooseResult -> onCurrencyChosen(chooseResult) } + .launchIn(modelScope) + } + + override fun onSearchQuery(query: SearchQuery) { onSearchQuery.trySend(query) } + override fun onCurrencyChosen(result: ChooseTokenResult) { + onCurrencyChosen.trySend(result) + onSearchQuery(SearchQuery.Empty) + } + + override fun onClose() { + onClose.trySend(Unit) + onSearchQuery(SearchQuery.Empty) + } + override fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) { _currenciesGroupFlow.update { currenciesGroup } } @AssistedFactory interface Factory : ChooseTokenBridge.Factory { - override fun create(modelScope: CoroutineScope): DefaultChooseTokenBridge + override fun create( + modelScope: CoroutineScope, + settings: Settings, + analyticsPayload: Set, + ): DefaultChooseTokenBridge } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index 02051e6d74..e8be72f01f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -17,8 +17,9 @@ import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.feature.swap.choosetoken.impl.model.ClickIntents -import com.tangem.feature.swap.choosetoken.impl.model.isSearchingState import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.toPersistentList @@ -27,7 +28,8 @@ internal class ChooseTokenListItemConverter( private val appCurrency: AppCurrency, private val params: TokenConverterParams, private val clickIntents: ClickIntents, - private val searchQuery: String, + private val searchQuery: SearchQuery, + private val tokenFilter: (AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean, ) { private val isSearchingState: Boolean get() = searchQuery.isSearchingState @@ -46,6 +48,7 @@ internal class ChooseTokenListItemConverter( return when (params) { is TokenConverterParams.Account -> convertAccountList(params) is TokenConverterParams.Wallet -> convertTokenList( + account = params.mainAccount, tokenConverter = tokenStatusConverter(params.mainAccount), tokenListParam = params.tokenList, ) @@ -99,7 +102,7 @@ internal class ChooseTokenListItemConverter( ) val accountItem = converter.convert(tokenList.totalFiatBalance) val tokenConverter = tokenStatusConverter(this) - val tokensListState = convertTokenList(tokenConverter, tokenList) + val tokensListState = convertTokenList(tokenConverter, tokenList, this) val items = tokensListState.tokensList return TokensListPortfolioItemConverter( tokenItemUM = accountItem, @@ -109,10 +112,12 @@ internal class ChooseTokenListItemConverter( ).convert(Unit) } - private fun convertTokenList(tokenConverter: TokenItemStateConverter, tokenListParam: TokenList): TokenListUMData { - val tokenList = if (isSearchingState) filterByQuery(tokenListParam) else tokenListParam - - return when (tokenList) { + private fun convertTokenList( + tokenConverter: TokenItemStateConverter, + tokenListParam: TokenList, + account: AccountStatus.CryptoPortfolio, + ): TokenListUMData { + return when (val tokenList = filterTokenList(tokenListParam, account)) { is TokenList.Empty -> TokenListUMData.EmptyList is TokenList.GroupedByNetwork -> TokenListUMData.TokenList( tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(), @@ -125,21 +130,21 @@ internal class ChooseTokenListItemConverter( } } - private fun filterByQuery(tokenList: TokenList): TokenList { - fun List.filterByQuery(): List = filter { currency -> - currency.currency.name.contains(searchQuery, ignoreCase = true) || - currency.currency.symbol.contains(searchQuery, ignoreCase = true) + private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList { + fun List.filterCurrencies(): List = filter { currency -> + currency.filterByQuery() && tokenFilter(account, currency) } + return when (tokenList) { TokenList.Empty -> TokenList.Empty is TokenList.Ungrouped -> { - val filtered = tokenList.currencies.filterByQuery() + val filtered = tokenList.currencies.filterCurrencies() if (filtered.isEmpty()) TokenList.Empty else tokenList.copy(currencies = filtered) } is TokenList.GroupedByNetwork -> { val filteredGroups = tokenList.groups .map { group -> - val filteredCurrencies = group.currencies.filterByQuery() + val filteredCurrencies = group.currencies.filterCurrencies() group.copy(currencies = filteredCurrencies) } .filter { group -> group.currencies.isNotEmpty() } @@ -147,4 +152,11 @@ internal class ChooseTokenListItemConverter( } } } + + private fun CryptoCurrencyStatus.filterByQuery(): Boolean { + if (!isSearchingState) return true + val isSearchFilter = currency.name.contains(searchQuery.value, ignoreCase = true) || + currency.symbol.contains(searchQuery.value, ignoreCase = true) + return isSearchFilter + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt index d0fd563a00..22ee9d1cbf 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt @@ -9,12 +9,12 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.AccountId -import com.tangem.domain.models.account.AccountStatus -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.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.choosetoken.api.* +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarToggleTransformer import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarUpdateQueryTransformer import com.tangem.feature.swap.choosetoken.impl.ui.* @@ -29,16 +29,12 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import javax.inject.Inject -internal val String.isSearchingState: Boolean get() = this.isNotBlank() -internal val StateFlow.isSearchingState: Boolean get() = this.value.isSearchingState - @Suppress("LongParameterList") @ModelScoped internal class ChooseTokenModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val settingContextUseCase: SettingContextUseCase, private val getWalletsUseCase: GetWalletsUseCase, - portfolioListBlockDelegateFactory: PortfolioListBlockDelegate.Factory, marketBlockDelegateFactory: MarketBlockDelegate.Factory, paramsContainer: ParamsContainer, ) : Model() { @@ -46,23 +42,19 @@ internal class ChooseTokenModel @Inject constructor( private val params = paramsContainer.require() private val bridge: ChooseTokenBridge = params.bridge - private val searchQueryState: StateFlow = bridge.searchQueryState + private val searchQueryState: StateFlow = bridge.searchQueryState private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create( modelScope = modelScope, searchQueryState = searchQueryState, - screensSourcesName = params.analyticsPayload + screensSourcesName = bridge.analyticsPayload .filterIsInstance() .firstOrNull()?.value.orEmpty(), ) - private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create( - modelScope = modelScope, - searchQueryState = searchQueryState, - ) val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager - private val marketsStateFlow: Flow = if (params.settings.isShowMarketBlock) { + private val marketsStateFlow: Flow = if (bridge.settings.isShowMarketBlock) { marketBlockDelegate.marketsStateFlow } else { flowOf(null) @@ -128,7 +120,7 @@ internal class ChooseTokenModel @Inject constructor( val result = ChooseTokenResultOld( account = account, cryptoCurrencyStatus = cryptoCurrencyStatus, - isSearched = searchQueryState.value.isNotEmpty(), + isSearched = searchQueryState.isSearchingState, ) bridge.onTokenSelected(result) }, @@ -165,7 +157,7 @@ internal class ChooseTokenModel @Inject constructor( val fullPortfolioBlockFlow = combine( flow = allWalletsFlow, - flow2 = portfolioListBlockDelegate.portfolioList, + flow2 = bridge.portfolioListBlock, flow3 = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), transform = { allWallets, portfolioList, selectedWalletId -> val tokensListData = portfolioList[selectedWalletId] ?: return@combine null @@ -188,16 +180,6 @@ internal class ChooseTokenModel @Inject constructor( .filterNotNull() .distinctUntilChanged() - portfolioListBlockDelegate.onTokenItemClick.receiveAsFlow() - .onEach { (account, currencyStatus) -> - onTokenItemClick( - wallet = allWalletsFlow.value[account.accountId.userWalletId] ?: return@onEach, - account = account, - currencyStatus = currencyStatus, - ) - } - .launchIn(this) - combine( flow = fullPortfolioBlockFlow, flow2 = settingContextUseCase.invoke(), @@ -223,19 +205,6 @@ internal class ChooseTokenModel @Inject constructor( .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) - private fun onTokenItemClick(wallet: UserWallet, account: AccountStatus, currencyStatus: CryptoCurrencyStatus) { - val analyticsPayload = setOf( - ChooseTokenAnalyticsPayload.IsSearched(isSearchingState), - ) - val result = ChooseTokenResult( - account = account, - currency = currencyStatus, - wallet = wallet, - analyticsPayload = analyticsPayload, - ) - bridge.onCurrencyChosen(result) - } - fun onBackClicked() { bridge.onClose() } @@ -254,7 +223,7 @@ internal class ChooseTokenModel @Inject constructor( ) private fun getInitState() = ChooseTokenInitialUM( - screenTitle = params.settings.title, + screenTitle = bridge.settings.title, onCloseClick = ::onBackClicked, searchBar = getInitialSearchBar(), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt index 36f5f5ebc2..e9c9ff93d4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt @@ -14,6 +14,8 @@ import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.feature.swap.models.AddToPortfolioRoute import com.tangem.feature.swap.models.market.MarketsListBatchFlowManager import com.tangem.feature.swap.models.market.state.SwapMarketState @@ -33,7 +35,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( private val getUserWalletsUseCase: GetWalletsUseCase, private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, @Assisted private val modelScope: CoroutineScope, - @Assisted private val searchQueryState: StateFlow, + @Assisted private val searchQueryState: StateFlow, @Assisted private val screensSourcesName: String, ) { @@ -49,7 +51,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( val marketsStateFlow: Flow = searchQueryState // Switch between default and search market flows - .map { it.isEmpty() } + .map { it.value.isEmpty() } .distinctUntilChanged() .flatMapLatest { isDefaultMode -> if (isDefaultMode) { @@ -74,7 +76,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( marketsListBatchFlowManagerFactory.create( batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, order = TokenMarketListConfig.Order.ByRating, - currentSearchText = Provider { searchQueryState.value }, + currentSearchText = Provider { searchQueryState.value.value }, modelScope = modelScope, ) } @@ -83,8 +85,8 @@ internal class MarketBlockDelegate @AssistedInject constructor( // Reload search markets when query changes searchQueryState .onEach { searchQuery -> - if (searchQuery.isNotEmpty()) { - searchMarketsListManager.reload(searchQuery) + if (searchQuery.isSearchingState) { + searchMarketsListManager.reload(searchQuery.value) } } .launchIn(modelScope) @@ -157,7 +159,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( ) { uiItems, isError, isSearchNotFound, total -> when { isError -> SwapMarketState.LoadingError( - onRetryClicked = { searchMarketsListManager.reload(searchQueryState.value) }, + onRetryClicked = { searchMarketsListManager.reload(searchQueryState.value.value) }, marketsTitle = marketsTitle, shouldAssetsCount = true, ) @@ -210,7 +212,7 @@ internal class MarketBlockDelegate @AssistedInject constructor( @AssistedFactory interface Factory { fun create( - searchQueryState: StateFlow, + searchQueryState: StateFlow, modelScope: CoroutineScope, screensSourcesName: String, ): MarketBlockDelegate diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt index 9e45ac853a..948e0763fe 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt @@ -11,6 +11,10 @@ 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.wallets.usecase.GetWalletsUseCase +import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase import com.tangem.feature.swap.choosetoken.impl.converter.ChooseTokenListItemConverter import com.tangem.feature.swap.models.TokenListUMData @@ -28,17 +32,35 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier, private val getWalletsUseCase: GetWalletsUseCase, @Assisted private val modelScope: CoroutineScope, - @Assisted private val searchQueryState: StateFlow, + @Assisted private val searchQueryState: StateFlow, ) : ClickIntents { - val onTokenItemClick: Channel> = Channel() + private val onTokenItemClick: Channel> = Channel() - val portfolioList: Flow> = flow { + val onTokenChosen: Channel = Channel() + val tokenFilter: MutableStateFlow<(AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean> = + MutableStateFlow { _, _ -> true } + + val portfolioList: Flow> = buildDataFlow() + .distinctUntilChanged() + .shareIn(modelScope, SharingStarted.Eagerly, replay = 1) + + private fun buildDataFlow(): Flow> = channelFlow { val allAccountsFlow: Flow> = multiAccountStatusListSupplier.invokeAsMap() - val allWalletsFlow: Flow> = - getWalletsUseCase.invokeAsMap() + val allWalletsFlow: StateFlow> = + getWalletsUseCase.invokeAsMap().stateIn(this) + + onTokenItemClick.receiveAsFlow() + .onEach { (account, currencyStatus) -> + onTokenItemClick( + wallet = allWalletsFlow.value[account.accountId.userWalletId] ?: return@onEach, + account = account, + currencyStatus = currencyStatus, + ) + } + .launchIn(this) val expandedAccountsMapFlow = allWalletsFlow .map { allWallets -> allWallets.values.map { wallet -> wallet.walletId } } @@ -51,7 +73,8 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( flow2 = allAccountsFlow, flow3 = expandedAccountsMapFlow, flow4 = searchQueryState, - transform = { settings, allAccounts, expandedAccountsMap, searchQuery -> + flow5 = tokenFilter, + transform = { settings, allAccounts, expandedAccountsMap, searchQuery, tokenFilter -> allAccounts.mapNotNullValues { (walletId, statusList) -> val expandedAccounts = expandedAccountsMap[walletId].orEmpty() val converterParams = if (settings.isAccountsMode) { @@ -65,16 +88,15 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( params = converterParams, clickIntents = this@PortfolioListBlockDelegate, searchQuery = searchQuery, + tokenFilter = tokenFilter, ).convert() um } }, ) - emitAll(finalFlow) + finalFlow.collectLatest { result -> channel.send(result) } } - .distinctUntilChanged() - .shareIn(modelScope, SharingStarted.Eagerly, replay = 1) private fun List.toExpandedAccountsMap(): Flow>> { if (isEmpty()) return flowOf(emptyMap()) @@ -83,6 +105,19 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( return combine(flows, { pairs -> pairs.toMap() }) } + private fun onTokenItemClick(wallet: UserWallet, account: AccountStatus, currencyStatus: CryptoCurrencyStatus) { + val analyticsPayload = setOf( + ChooseTokenAnalyticsPayload.IsSearched(searchQueryState.isSearchingState), + ) + val result = ChooseTokenResult( + account = account, + currency = currencyStatus, + wallet = wallet, + analyticsPayload = analyticsPayload, + ) + onTokenChosen.trySend(result) + } + override fun onTokenItemClick(account: AccountStatus, currencyStatus: CryptoCurrencyStatus) { onTokenItemClick.trySend(account to currencyStatus) } @@ -97,7 +132,7 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(searchQueryState: StateFlow, modelScope: CoroutineScope): PortfolioListBlockDelegate + fun create(searchQueryState: StateFlow, modelScope: CoroutineScope): PortfolioListBlockDelegate } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt index a02d3a52c2..1081546f78 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt @@ -121,12 +121,11 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { assetsTitle() if (state.contentUM != null) { + walletListItem(state.contentUM.walletList) when { state.contentUM.isNotFoundState -> tokensNotFound() state.contentUM.isEmptyState -> emptyTokensList() else -> { - walletListItem(state.contentUM.walletList) - tokensListItems( tokensListData = state.contentUM.tokensListData, isBalanceHidden = state.contentUM.isBalanceHidden, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 288c865b8e..eb2222f685 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -72,6 +72,7 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.analytics.SwapEvents +import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter @@ -179,7 +180,13 @@ internal class SwapModel @Inject constructor( private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() - val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create(modelScope) + val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + modelScope = modelScope, + settings = ChooseTokenBridge.Settings.SwapTo, + analyticsPayload = setOf( + ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value), + ), + ) private val stateBuilder = StateBuilder( userWalletProvider = Provider { userWallet }, @@ -287,7 +294,7 @@ internal class SwapModel @Inject constructor( init { chooseTokenBridge.searchQueryState - .onEach { query -> onSearchEntered(query) } + .onEach { query -> onSearchEntered(query.value) } .launchIn(modelScope) chooseTokenBridge.onNewTokenAdded.receiveAsFlow() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index 950ef764ad..1c40cf6de6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -15,7 +15,7 @@ internal data class SwapSelectTokenStateHolder( ) @Immutable -internal sealed interface TokenListUMData { +sealed interface TokenListUMData { val tokensList: ImmutableList val totalTokensCount: Int From 93c12768283cf7a513ef8e0b76e9235db0f8f82f Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Apr 2026 15:24:55 +0400 Subject: [PATCH 053/206] Updated on 2026-08-14 --- common/ui/build.gradle.kts | 8 + .../common/ui/extensions/BlockchainIcons.kt | 311 ++++++++++++++++++ .../ui/extensions/BlockchainIconsTest.kt | 256 ++++++++++++++ 3 files changed, 575 insertions(+) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt create mode 100644 common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt diff --git a/common/ui/build.gradle.kts b/common/ui/build.gradle.kts index 71620a30d6..31eb3dc544 100644 --- a/common/ui/build.gradle.kts +++ b/common/ui/build.gradle.kts @@ -8,6 +8,10 @@ android { namespace = "com.tangem.common.ui" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { api(projects.common) @@ -48,4 +52,8 @@ dependencies { implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + + /** Tests */ + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt b/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt new file mode 100644 index 0000000000..4b128eff63 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/extensions/BlockchainIcons.kt @@ -0,0 +1,311 @@ +package com.tangem.common.ui.extensions + +import androidx.annotation.DrawableRes +import com.tangem.blockchain.common.Blockchain +import com.tangem.core.ui.R + +/** + * Holds icon resources for a single [Blockchain]. + * + * @property active drawable for the active state + * @property greyedOut drawable for the disabled / greyed-out state + */ +private data class IconSet( + @DrawableRes val active: Int, + @DrawableRes val greyedOut: Int, +) + +/** + * Returns the [IconSet] for the given [blockchain], or `null` if icons are not yet available. + * + * The `when` is exhaustive — adding a new [Blockchain] entry in the SDK without handling it here + * will cause a compile-time error. + */ +@Suppress("CyclomaticComplexMethod", "LongMethod") +private fun iconSetOf(blockchain: Blockchain): IconSet? = when (blockchain) { + Blockchain.Alephium, + Blockchain.AlephiumTestnet, + -> IconSet(active = R.drawable.img_alephium_22, greyedOut = R.drawable.ic_alephium_22) + Blockchain.AlephZero, + Blockchain.AlephZeroTestnet, + -> IconSet(active = R.drawable.img_azero_22, greyedOut = R.drawable.ic_azero_22) + Blockchain.Algorand, + Blockchain.AlgorandTestnet, + -> IconSet(active = R.drawable.img_algorand_22, greyedOut = R.drawable.ic_algorand_22) + Blockchain.ApeChain, + Blockchain.ApeChainTestnet, + -> IconSet(active = R.drawable.img_apecoin_22, greyedOut = R.drawable.ic_apecoin_22) + Blockchain.Aptos, + Blockchain.AptosTestnet, + -> IconSet(active = R.drawable.img_aptos_22, greyedOut = R.drawable.ic_aptos_22) + Blockchain.Arbitrum, + Blockchain.ArbitrumTestnet, + -> IconSet(active = R.drawable.img_arbitrum_22, greyedOut = R.drawable.ic_arbitrum_22) + Blockchain.ArbitrumNova, + -> IconSet(active = R.drawable.img_arbitrum_nova_22, greyedOut = R.drawable.ic_arbitrum_nova_22) + Blockchain.Areon, + Blockchain.AreonTestnet, + -> IconSet(active = R.drawable.img_areon_22, greyedOut = R.drawable.ic_areon_22) + Blockchain.Aurora, + Blockchain.AuroraTestnet, + -> IconSet(active = R.drawable.img_aurora_22, greyedOut = R.drawable.ic_aurora_22) + Blockchain.Avalanche, + Blockchain.AvalancheTestnet, + -> IconSet(active = R.drawable.img_avalanche_22, greyedOut = R.drawable.ic_avalanche_22) + Blockchain.BSC, + Blockchain.BSCTestnet, + Blockchain.Binance, + Blockchain.BinanceTestnet, + -> IconSet(active = R.drawable.img_bsc_22, greyedOut = R.drawable.ic_bsc_16) + Blockchain.Base, + Blockchain.BaseTestnet, + -> IconSet(active = R.drawable.img_base_22, greyedOut = R.drawable.ic_base_22) + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + -> IconSet(active = R.drawable.img_btc_22, greyedOut = R.drawable.ic_bitcoin_16) + Blockchain.BitcoinCash, + Blockchain.BitcoinCashTestnet, + -> IconSet(active = R.drawable.img_btc_cash_22, greyedOut = R.drawable.ic_bitcoin_cash_16) + Blockchain.Bitrock, + Blockchain.BitrockTestnet, + -> IconSet(active = R.drawable.img_bitrock_22, greyedOut = R.drawable.ic_bitrock_22) + Blockchain.Bittensor, + -> IconSet(active = R.drawable.img_bittensor_22, greyedOut = R.drawable.ic_bittensor_22) + Blockchain.Blast, + Blockchain.BlastTestnet, + -> IconSet(active = R.drawable.img_blast_22, greyedOut = R.drawable.ic_blast_22) + Blockchain.Canxium, + -> IconSet(active = R.drawable.img_canxium_22, greyedOut = R.drawable.ic_canxium_22) + Blockchain.Cardano, + -> IconSet(active = R.drawable.img_cardano_22, greyedOut = R.drawable.ic_cardano_16) + Blockchain.Casper, + Blockchain.CasperTestnet, + -> IconSet(active = R.drawable.img_casper_22, greyedOut = R.drawable.ic_casper_22) + Blockchain.Chia, + Blockchain.ChiaTestnet, + -> IconSet(active = R.drawable.img_chia_22, greyedOut = R.drawable.ic_chia_22) + Blockchain.Chiliz, + Blockchain.ChilizTestnet, + -> IconSet(active = R.drawable.img_chiliz_22, greyedOut = R.drawable.ic_chiliz_22) + Blockchain.Clore, + -> IconSet(active = R.drawable.img_clore_22, greyedOut = R.drawable.ic_clore_22) + Blockchain.Core, + Blockchain.CoreTestnet, + -> IconSet(active = R.drawable.img_core_22, greyedOut = R.drawable.ic_core_22) + Blockchain.Cosmos, + Blockchain.CosmosTestnet, + -> IconSet(active = R.drawable.img_cosmos_22, greyedOut = R.drawable.ic_cosmos_22) + Blockchain.Cronos, + -> IconSet(active = R.drawable.img_cronos_22, greyedOut = R.drawable.ic_cronos_22) + Blockchain.Cyber, + Blockchain.CyberTestnet, + -> IconSet(active = R.drawable.img_cyber_22, greyedOut = R.drawable.ic_cyber_22) + Blockchain.Dash, + -> IconSet(active = R.drawable.img_dash_22, greyedOut = R.drawable.ic_dash_22) + Blockchain.Decimal, + Blockchain.DecimalTestnet, + -> IconSet(active = R.drawable.img_decimal_22, greyedOut = R.drawable.ic_decimal_22) + Blockchain.Dischain, + -> IconSet(active = R.drawable.img_dischain_22, greyedOut = R.drawable.ic_dischain_22) + Blockchain.Dogecoin, + -> IconSet(active = R.drawable.img_dogecoin_22, greyedOut = R.drawable.ic_dogecoin_16) + Blockchain.Ducatus, + -> IconSet(active = R.drawable.img_ducatus_22, greyedOut = R.drawable.ic_ducatus_22) + Blockchain.EnergyWebChain, + Blockchain.EnergyWebChainTestnet, + Blockchain.EnergyWebX, + Blockchain.EnergyWebXTestnet, + -> IconSet(active = R.drawable.img_energy_web_22, greyedOut = R.drawable.ic_energy_web_22) + Blockchain.Ethereum, + Blockchain.EthereumTestnet, + -> IconSet(active = R.drawable.img_eth_22, greyedOut = R.drawable.ic_eth_16) + Blockchain.EthereumClassic, + Blockchain.EthereumClassicTestnet, + -> IconSet(active = R.drawable.img_eth_classic_22, greyedOut = R.drawable.ic_eth_16) + Blockchain.EthereumPow, + Blockchain.EthereumPowTestnet, + -> IconSet(active = R.drawable.img_eth_pow_22, greyedOut = R.drawable.ic_ethereumpow_22) + Blockchain.Fact0rn, + -> IconSet(active = R.drawable.img_fact0rn_22, greyedOut = R.drawable.ic_fact0rn_22) + Blockchain.Fantom, + Blockchain.FantomTestnet, + -> IconSet(active = R.drawable.img_fantom_22, greyedOut = R.drawable.ic_fantom_22) + Blockchain.Filecoin, + -> IconSet(active = R.drawable.img_filecoin_22, greyedOut = R.drawable.ic_filecoin_22) + Blockchain.Flare, + Blockchain.FlareTestnet, + -> IconSet(active = R.drawable.img_flare_22, greyedOut = R.drawable.ic_flare_22) + Blockchain.Gnosis, + -> IconSet(active = R.drawable.img_gnosis_22, greyedOut = R.drawable.ic_gnosis_22) + Blockchain.Hedera, + Blockchain.HederaTestnet, + -> IconSet(active = R.drawable.img_hedera_22, greyedOut = R.drawable.ic_hedera_22) + Blockchain.Hyperliquid, + Blockchain.HyperliquidTestnet, + -> IconSet(active = R.drawable.img_hyperliquid_22, greyedOut = R.drawable.ic_hyperliquid_22) + Blockchain.InternetComputer, + -> IconSet(active = R.drawable.img_icp_22, greyedOut = R.drawable.ic_icp_22) + Blockchain.Joystream, + -> IconSet(active = R.drawable.img_joystream_22, greyedOut = R.drawable.ic_joystream_22) + Blockchain.Kaspa, + Blockchain.KaspaTestnet, + -> IconSet(active = R.drawable.img_kaspa_22, greyedOut = R.drawable.ic_kaspa_22) + Blockchain.Kava, + Blockchain.KavaTestnet, + -> IconSet(active = R.drawable.img_kava_22, greyedOut = R.drawable.ic_kava_22) + Blockchain.Koinos, + Blockchain.KoinosTestnet, + -> IconSet(active = R.drawable.img_koinos_22, greyedOut = R.drawable.ic_koinos_22) + Blockchain.Kusama, + -> IconSet(active = R.drawable.img_kusama_22, greyedOut = R.drawable.ic_kusama_16) + Blockchain.Linea, + Blockchain.LineaTestnet, + -> IconSet(active = R.drawable.img_linea_22, greyedOut = R.drawable.ic_linea_22) + Blockchain.Litecoin, + -> IconSet(active = R.drawable.img_litecoin_22, greyedOut = R.drawable.ic_litecoin_22) + Blockchain.Manta, + Blockchain.MantaTestnet, + -> IconSet(active = R.drawable.img_manta_22, greyedOut = R.drawable.ic_manta_22) + Blockchain.Mantle, + Blockchain.MantleTestnet, + -> IconSet(active = R.drawable.img_mantle_22, greyedOut = R.drawable.ic_mantle_22) + Blockchain.Monad, + Blockchain.MonadTestnet, + -> IconSet(active = R.drawable.img_monad_22, greyedOut = R.drawable.ic_monad_22) + Blockchain.Moonbeam, + Blockchain.MoonbeamTestnet, + -> IconSet(active = R.drawable.img_moonbeam_22, greyedOut = R.drawable.ic_moonbeam_22) + Blockchain.Moonriver, + Blockchain.MoonriverTestnet, + -> IconSet(active = R.drawable.img_moonriver_22, greyedOut = R.drawable.ic_moonriver_22) + Blockchain.Near, + Blockchain.NearTestnet, + -> IconSet(active = R.drawable.img_near_22, greyedOut = R.drawable.ic_near_22) + Blockchain.OctaSpace, + Blockchain.OctaSpaceTestnet, + -> IconSet(active = R.drawable.img_octaspace_22, greyedOut = R.drawable.ic_octaspace_22) + Blockchain.OdysseyChain, + Blockchain.OdysseyChainTestnet, + -> IconSet(active = R.drawable.img_odyssey_chain_22, greyedOut = R.drawable.ic_odyssey_chain_22) + Blockchain.Optimism, + Blockchain.OptimismTestnet, + -> IconSet(active = R.drawable.img_optimism_22, greyedOut = R.drawable.ic_optimism_22) + Blockchain.Pepecoin, + Blockchain.PepecoinTestnet, + -> IconSet(active = R.drawable.img_pepecoin_22, greyedOut = R.drawable.ic_pepecoin_22) + Blockchain.Plasma, + Blockchain.PlasmaTestnet, + -> IconSet(active = R.drawable.img_plasma_22, greyedOut = R.drawable.ic_plasma_22) + Blockchain.Playa3ull, + -> IconSet(active = R.drawable.img_playa3ull_22, greyedOut = R.drawable.ic_playa3ull_22) + Blockchain.Polkadot, + Blockchain.PolkadotTestnet, + -> IconSet(active = R.drawable.img_polkadot_22, greyedOut = R.drawable.ic_polkadot_16) + Blockchain.Polygon, + Blockchain.PolygonTestnet, + -> IconSet(active = R.drawable.img_polygon_22, greyedOut = R.drawable.ic_polygon_22) + Blockchain.PolygonZkEVM, + Blockchain.PolygonZkEVMTestnet, + -> IconSet(active = R.drawable.img_polygon_22, greyedOut = R.drawable.ic_polygon_22) + Blockchain.PulseChain, + Blockchain.PulseChainTestnet, + -> IconSet(active = R.drawable.img_pls_22, greyedOut = R.drawable.ic_pls_22) + Blockchain.Quai, + Blockchain.QuaiTestnet, + -> IconSet(active = R.drawable.img_quai_22, greyedOut = R.drawable.ic_quai_22) + Blockchain.RSK, + -> IconSet(active = R.drawable.img_rsk_22, greyedOut = R.drawable.ic_rsk_16) + Blockchain.Radiant, + -> IconSet(active = R.drawable.img_radiant_22, greyedOut = R.drawable.ic_radiant_22) + Blockchain.Ravencoin, + Blockchain.RavencoinTestnet, + -> IconSet(active = R.drawable.img_ravencoin_22, greyedOut = R.drawable.ic_ravencoin_22) + Blockchain.Scroll, + Blockchain.ScrollTestnet, + -> IconSet(active = R.drawable.img_scroll_22, greyedOut = R.drawable.ic_scroll_22) + Blockchain.Sei, + Blockchain.SeiTestnet, + -> IconSet(active = R.drawable.img_sei_22, greyedOut = R.drawable.ic_sei_22) + Blockchain.Shibarium, + Blockchain.ShibariumTestnet, + -> IconSet(active = R.drawable.img_shibarium_22, greyedOut = R.drawable.ic_shibarium_22) + Blockchain.Solana, + Blockchain.SolanaTestnet, + -> IconSet(active = R.drawable.img_solana_22, greyedOut = R.drawable.ic_solana_16) + Blockchain.Sonic, + Blockchain.SonicTestnet, + -> IconSet(active = R.drawable.img_sonic_22, greyedOut = R.drawable.ic_sonic_22) + Blockchain.Stellar, + Blockchain.StellarTestnet, + -> IconSet(active = R.drawable.img_stellar_22, greyedOut = R.drawable.ic_stellar_16) + Blockchain.Sui, + Blockchain.SuiTestnet, + -> IconSet(active = R.drawable.img_sui_22, greyedOut = R.drawable.ic_sui_22) + Blockchain.TON, + Blockchain.TONTestnet, + -> IconSet(active = R.drawable.img_ton_22, greyedOut = R.drawable.ic_ton_22) + Blockchain.Taraxa, + Blockchain.TaraxaTestnet, + -> IconSet(active = R.drawable.img_taraxa_22, greyedOut = R.drawable.ic_taraxa_22) + Blockchain.Telos, + Blockchain.TelosTestnet, + -> IconSet(active = R.drawable.img_telos_22, greyedOut = R.drawable.ic_telos_22) + Blockchain.TerraV1, + -> IconSet(active = R.drawable.img_terra_22, greyedOut = R.drawable.ic_terra_22) + Blockchain.TerraV2, + -> IconSet(active = R.drawable.img_terra2_22, greyedOut = R.drawable.ic_terra2_22) + Blockchain.Tezos, + -> IconSet(active = R.drawable.img_tezos_22, greyedOut = R.drawable.ic_tezos_16) + Blockchain.Tron, + Blockchain.TronTestnet, + -> IconSet(active = R.drawable.img_tron_22, greyedOut = R.drawable.ic_tron_22) + Blockchain.VanarChain, + Blockchain.VanarChainTestnet, + -> IconSet(active = R.drawable.img_vanar_22, greyedOut = R.drawable.ic_vanar_22) + Blockchain.VeChain, + Blockchain.VeChainTestnet, + -> IconSet(active = R.drawable.img_vechain_22, greyedOut = R.drawable.ic_vechain_22) + Blockchain.XDC, + Blockchain.XDCTestnet, + -> IconSet(active = R.drawable.img_xdc_22, greyedOut = R.drawable.ic_xdc_22) + Blockchain.XRP, + -> IconSet(active = R.drawable.img_xrp_22, greyedOut = R.drawable.ic_xrp_22) + Blockchain.Xodex, + -> IconSet(active = R.drawable.img_xodex_22, greyedOut = R.drawable.ic_xodex_22) + Blockchain.ZkLinkNova, + Blockchain.ZkLinkNovaTestnet, + -> IconSet(active = R.drawable.img_zklink_22, greyedOut = R.drawable.ic_zklink_22) + Blockchain.ZkSyncEra, + Blockchain.ZkSyncEraTestnet, + -> IconSet(active = R.drawable.img_zksync_22, greyedOut = R.drawable.ic_zksync_22) + Blockchain.Nexa, + Blockchain.NexaTestnet, + Blockchain.Unknown, + -> null +} + +private val ICONS_BY_BLOCKCHAIN: Map = Blockchain.entries + .mapNotNull { blockchain -> iconSetOf(blockchain)?.let { blockchain to it } } + .toMap() + +/** + * Returns the active (colored) icon drawable resource for the given [blockchain]. + * + * @param blockchain the blockchain to look up + * @param fallback drawable returned when [blockchain] has no icon defined + */ +@DrawableRes +fun getActiveIconRes(blockchain: Blockchain, @DrawableRes fallback: Int = R.drawable.ic_alert_24): Int { + return ICONS_BY_BLOCKCHAIN[blockchain]?.active ?: fallback +} + +/** + * Returns the greyed-out (disabled) icon drawable resource for the given [blockchain]. + * + * @param blockchain the blockchain to look up + * @param fallback drawable returned when [blockchain] has no icon defined + */ +@DrawableRes +fun getGreyedOutIconRes(blockchain: Blockchain, @DrawableRes fallback: Int = R.drawable.ic_alert_24): Int { + return ICONS_BY_BLOCKCHAIN[blockchain]?.greyedOut ?: fallback +} \ No newline at end of file diff --git a/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt b/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt new file mode 100644 index 0000000000..016c6ac306 --- /dev/null +++ b/common/ui/src/test/kotlin/com/tangem/common/ui/extensions/BlockchainIconsTest.kt @@ -0,0 +1,256 @@ +package com.tangem.common.ui.extensions + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.core.ui.R +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class BlockchainIconsTest { + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetActiveIconRes { + + @ParameterizedTest + @ProvideTestModels + fun returnsCorrectDrawable(model: TestModel) { + assertThat(getActiveIconRes(model.input)).isEqualTo(model.expected) + } + + @Test + fun returnsAlertDrawableForUnknownId() { + assertThat(getActiveIconRes(Blockchain.Unknown)).isEqualTo(R.drawable.ic_alert_24) + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + private fun provideTestModels() = Blockchain.entries.map { blockchain -> + val expected = when (blockchain) { + Blockchain.Alephium, Blockchain.AlephiumTestnet -> R.drawable.img_alephium_22 + Blockchain.AlephZero, Blockchain.AlephZeroTestnet -> R.drawable.img_azero_22 + Blockchain.Algorand, Blockchain.AlgorandTestnet -> R.drawable.img_algorand_22 + Blockchain.ApeChain, Blockchain.ApeChainTestnet -> R.drawable.img_apecoin_22 + Blockchain.Aptos, Blockchain.AptosTestnet -> R.drawable.img_aptos_22 + Blockchain.Arbitrum, Blockchain.ArbitrumTestnet -> R.drawable.img_arbitrum_22 + Blockchain.ArbitrumNova -> R.drawable.img_arbitrum_nova_22 + Blockchain.Areon, Blockchain.AreonTestnet -> R.drawable.img_areon_22 + Blockchain.Aurora, Blockchain.AuroraTestnet -> R.drawable.img_aurora_22 + Blockchain.Avalanche, Blockchain.AvalancheTestnet -> R.drawable.img_avalanche_22 + Blockchain.BSC, Blockchain.BSCTestnet, + Blockchain.Binance, Blockchain.BinanceTestnet, + -> R.drawable.img_bsc_22 + Blockchain.Base, Blockchain.BaseTestnet -> R.drawable.img_base_22 + Blockchain.Bitcoin, Blockchain.BitcoinTestnet -> R.drawable.img_btc_22 + Blockchain.BitcoinCash, Blockchain.BitcoinCashTestnet -> R.drawable.img_btc_cash_22 + Blockchain.Bitrock, Blockchain.BitrockTestnet -> R.drawable.img_bitrock_22 + Blockchain.Bittensor -> R.drawable.img_bittensor_22 + Blockchain.Blast, Blockchain.BlastTestnet -> R.drawable.img_blast_22 + Blockchain.Canxium -> R.drawable.img_canxium_22 + Blockchain.Cardano -> R.drawable.img_cardano_22 + Blockchain.Casper, Blockchain.CasperTestnet -> R.drawable.img_casper_22 + Blockchain.Chia, Blockchain.ChiaTestnet -> R.drawable.img_chia_22 + Blockchain.Chiliz, Blockchain.ChilizTestnet -> R.drawable.img_chiliz_22 + Blockchain.Clore -> R.drawable.img_clore_22 + Blockchain.Core, Blockchain.CoreTestnet -> R.drawable.img_core_22 + Blockchain.Cosmos, Blockchain.CosmosTestnet -> R.drawable.img_cosmos_22 + Blockchain.Cronos -> R.drawable.img_cronos_22 + Blockchain.Cyber, Blockchain.CyberTestnet -> R.drawable.img_cyber_22 + Blockchain.Dash -> R.drawable.img_dash_22 + Blockchain.Decimal, Blockchain.DecimalTestnet -> R.drawable.img_decimal_22 + Blockchain.Dischain -> R.drawable.img_dischain_22 + Blockchain.Dogecoin -> R.drawable.img_dogecoin_22 + Blockchain.Ducatus -> R.drawable.img_ducatus_22 + Blockchain.EnergyWebChain, Blockchain.EnergyWebChainTestnet, + Blockchain.EnergyWebX, Blockchain.EnergyWebXTestnet, + -> R.drawable.img_energy_web_22 + Blockchain.Ethereum, Blockchain.EthereumTestnet -> R.drawable.img_eth_22 + Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet -> R.drawable.img_eth_classic_22 + Blockchain.EthereumPow, Blockchain.EthereumPowTestnet -> R.drawable.img_eth_pow_22 + Blockchain.Fact0rn -> R.drawable.img_fact0rn_22 + Blockchain.Fantom, Blockchain.FantomTestnet -> R.drawable.img_fantom_22 + Blockchain.Filecoin -> R.drawable.img_filecoin_22 + Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.img_flare_22 + Blockchain.Gnosis -> R.drawable.img_gnosis_22 + Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.img_hedera_22 + Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.img_hyperliquid_22 + Blockchain.InternetComputer -> R.drawable.img_icp_22 + Blockchain.Joystream -> R.drawable.img_joystream_22 + Blockchain.Kaspa, Blockchain.KaspaTestnet -> R.drawable.img_kaspa_22 + Blockchain.Kava, Blockchain.KavaTestnet -> R.drawable.img_kava_22 + Blockchain.Koinos, Blockchain.KoinosTestnet -> R.drawable.img_koinos_22 + Blockchain.Kusama -> R.drawable.img_kusama_22 + Blockchain.Linea, Blockchain.LineaTestnet -> R.drawable.img_linea_22 + Blockchain.Litecoin -> R.drawable.img_litecoin_22 + Blockchain.Manta, Blockchain.MantaTestnet -> R.drawable.img_manta_22 + Blockchain.Mantle, Blockchain.MantleTestnet -> R.drawable.img_mantle_22 + Blockchain.Monad, Blockchain.MonadTestnet -> R.drawable.img_monad_22 + Blockchain.Moonbeam, Blockchain.MoonbeamTestnet -> R.drawable.img_moonbeam_22 + Blockchain.Moonriver, Blockchain.MoonriverTestnet -> R.drawable.img_moonriver_22 + Blockchain.Near, Blockchain.NearTestnet -> R.drawable.img_near_22 + Blockchain.OctaSpace, Blockchain.OctaSpaceTestnet -> R.drawable.img_octaspace_22 + Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet -> R.drawable.img_odyssey_chain_22 + Blockchain.Optimism, Blockchain.OptimismTestnet -> R.drawable.img_optimism_22 + Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> R.drawable.img_pepecoin_22 + Blockchain.Plasma, Blockchain.PlasmaTestnet -> R.drawable.img_plasma_22 + Blockchain.Playa3ull -> R.drawable.img_playa3ull_22 + Blockchain.Polkadot, Blockchain.PolkadotTestnet -> R.drawable.img_polkadot_22 + Blockchain.Polygon, Blockchain.PolygonTestnet -> R.drawable.img_polygon_22 + Blockchain.PolygonZkEVM, Blockchain.PolygonZkEVMTestnet -> R.drawable.img_polygon_22 + Blockchain.PulseChain, Blockchain.PulseChainTestnet -> R.drawable.img_pls_22 + Blockchain.Quai, Blockchain.QuaiTestnet -> R.drawable.img_quai_22 + Blockchain.RSK -> R.drawable.img_rsk_22 + Blockchain.Radiant -> R.drawable.img_radiant_22 + Blockchain.Ravencoin, Blockchain.RavencoinTestnet -> R.drawable.img_ravencoin_22 + Blockchain.Scroll, Blockchain.ScrollTestnet -> R.drawable.img_scroll_22 + Blockchain.Sei, Blockchain.SeiTestnet -> R.drawable.img_sei_22 + Blockchain.Shibarium, Blockchain.ShibariumTestnet -> R.drawable.img_shibarium_22 + Blockchain.Solana, Blockchain.SolanaTestnet -> R.drawable.img_solana_22 + Blockchain.Sonic, Blockchain.SonicTestnet -> R.drawable.img_sonic_22 + Blockchain.Stellar, Blockchain.StellarTestnet -> R.drawable.img_stellar_22 + Blockchain.Sui, Blockchain.SuiTestnet -> R.drawable.img_sui_22 + Blockchain.TON, Blockchain.TONTestnet -> R.drawable.img_ton_22 + Blockchain.Taraxa, Blockchain.TaraxaTestnet -> R.drawable.img_taraxa_22 + Blockchain.Telos, Blockchain.TelosTestnet -> R.drawable.img_telos_22 + Blockchain.TerraV1 -> R.drawable.img_terra_22 + Blockchain.TerraV2 -> R.drawable.img_terra2_22 + Blockchain.Tezos -> R.drawable.img_tezos_22 + Blockchain.Tron, Blockchain.TronTestnet -> R.drawable.img_tron_22 + Blockchain.VanarChain, Blockchain.VanarChainTestnet -> R.drawable.img_vanar_22 + Blockchain.VeChain, Blockchain.VeChainTestnet -> R.drawable.img_vechain_22 + Blockchain.XDC, Blockchain.XDCTestnet -> R.drawable.img_xdc_22 + Blockchain.XRP -> R.drawable.img_xrp_22 + Blockchain.Xodex -> R.drawable.img_xodex_22 + Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet -> R.drawable.img_zklink_22 + Blockchain.ZkSyncEra, Blockchain.ZkSyncEraTestnet -> R.drawable.img_zksync_22 + Blockchain.Nexa, Blockchain.NexaTestnet, Blockchain.Unknown -> R.drawable.ic_alert_24 + } + TestModel(blockchain, expected) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetGreyedOutIconRes { + + @ParameterizedTest + @ProvideTestModels + fun returnsCorrectDrawable(model: TestModel) { + assertThat(getGreyedOutIconRes(model.input)).isEqualTo(model.expected) + } + + @Test + fun returnsAlertDrawableForUnknownId() { + assertThat(getGreyedOutIconRes(Blockchain.Unknown)).isEqualTo(R.drawable.ic_alert_24) + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + private fun provideTestModels() = Blockchain.entries.map { blockchain -> + val expected = when (blockchain) { + Blockchain.Alephium, Blockchain.AlephiumTestnet -> R.drawable.ic_alephium_22 + Blockchain.AlephZero, Blockchain.AlephZeroTestnet -> R.drawable.ic_azero_22 + Blockchain.Algorand, Blockchain.AlgorandTestnet -> R.drawable.ic_algorand_22 + Blockchain.ApeChain, Blockchain.ApeChainTestnet -> R.drawable.ic_apecoin_22 + Blockchain.Aptos, Blockchain.AptosTestnet -> R.drawable.ic_aptos_22 + Blockchain.Arbitrum, Blockchain.ArbitrumTestnet -> R.drawable.ic_arbitrum_22 + Blockchain.ArbitrumNova -> R.drawable.ic_arbitrum_nova_22 + Blockchain.Areon, Blockchain.AreonTestnet -> R.drawable.ic_areon_22 + Blockchain.Aurora, Blockchain.AuroraTestnet -> R.drawable.ic_aurora_22 + Blockchain.Avalanche, Blockchain.AvalancheTestnet -> R.drawable.ic_avalanche_22 + Blockchain.BSC, Blockchain.BSCTestnet, + Blockchain.Binance, Blockchain.BinanceTestnet, + -> R.drawable.ic_bsc_16 + Blockchain.Base, Blockchain.BaseTestnet -> R.drawable.ic_base_22 + Blockchain.Bitcoin, Blockchain.BitcoinTestnet -> R.drawable.ic_bitcoin_16 + Blockchain.BitcoinCash, Blockchain.BitcoinCashTestnet -> R.drawable.ic_bitcoin_cash_16 + Blockchain.Bitrock, Blockchain.BitrockTestnet -> R.drawable.ic_bitrock_22 + Blockchain.Bittensor -> R.drawable.ic_bittensor_22 + Blockchain.Blast, Blockchain.BlastTestnet -> R.drawable.ic_blast_22 + Blockchain.Canxium -> R.drawable.ic_canxium_22 + Blockchain.Cardano -> R.drawable.ic_cardano_16 + Blockchain.Casper, Blockchain.CasperTestnet -> R.drawable.ic_casper_22 + Blockchain.Chia, Blockchain.ChiaTestnet -> R.drawable.ic_chia_22 + Blockchain.Chiliz, Blockchain.ChilizTestnet -> R.drawable.ic_chiliz_22 + Blockchain.Clore -> R.drawable.ic_clore_22 + Blockchain.Core, Blockchain.CoreTestnet -> R.drawable.ic_core_22 + Blockchain.Cosmos, Blockchain.CosmosTestnet -> R.drawable.ic_cosmos_22 + Blockchain.Cronos -> R.drawable.ic_cronos_22 + Blockchain.Cyber, Blockchain.CyberTestnet -> R.drawable.ic_cyber_22 + Blockchain.Dash -> R.drawable.ic_dash_22 + Blockchain.Decimal, Blockchain.DecimalTestnet -> R.drawable.ic_decimal_22 + Blockchain.Dischain -> R.drawable.ic_dischain_22 + Blockchain.Dogecoin -> R.drawable.ic_dogecoin_16 + Blockchain.Ducatus -> R.drawable.ic_ducatus_22 + Blockchain.EnergyWebChain, Blockchain.EnergyWebChainTestnet, + Blockchain.EnergyWebX, Blockchain.EnergyWebXTestnet, + -> R.drawable.ic_energy_web_22 + Blockchain.Ethereum, Blockchain.EthereumTestnet -> R.drawable.ic_eth_16 + Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet -> R.drawable.ic_eth_16 + Blockchain.EthereumPow, Blockchain.EthereumPowTestnet -> R.drawable.ic_ethereumpow_22 + Blockchain.Fact0rn -> R.drawable.ic_fact0rn_22 + Blockchain.Fantom, Blockchain.FantomTestnet -> R.drawable.ic_fantom_22 + Blockchain.Filecoin -> R.drawable.ic_filecoin_22 + Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.ic_flare_22 + Blockchain.Gnosis -> R.drawable.ic_gnosis_22 + Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.ic_hedera_22 + Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.ic_hyperliquid_22 + Blockchain.InternetComputer -> R.drawable.ic_icp_22 + Blockchain.Joystream -> R.drawable.ic_joystream_22 + Blockchain.Kaspa, Blockchain.KaspaTestnet -> R.drawable.ic_kaspa_22 + Blockchain.Kava, Blockchain.KavaTestnet -> R.drawable.ic_kava_22 + Blockchain.Koinos, Blockchain.KoinosTestnet -> R.drawable.ic_koinos_22 + Blockchain.Kusama -> R.drawable.ic_kusama_16 + Blockchain.Linea, Blockchain.LineaTestnet -> R.drawable.ic_linea_22 + Blockchain.Litecoin -> R.drawable.ic_litecoin_22 + Blockchain.Manta, Blockchain.MantaTestnet -> R.drawable.ic_manta_22 + Blockchain.Mantle, Blockchain.MantleTestnet -> R.drawable.ic_mantle_22 + Blockchain.Monad, Blockchain.MonadTestnet -> R.drawable.ic_monad_22 + Blockchain.Moonbeam, Blockchain.MoonbeamTestnet -> R.drawable.ic_moonbeam_22 + Blockchain.Moonriver, Blockchain.MoonriverTestnet -> R.drawable.ic_moonriver_22 + Blockchain.Near, Blockchain.NearTestnet -> R.drawable.ic_near_22 + Blockchain.OctaSpace, Blockchain.OctaSpaceTestnet -> R.drawable.ic_octaspace_22 + Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet -> R.drawable.ic_odyssey_chain_22 + Blockchain.Optimism, Blockchain.OptimismTestnet -> R.drawable.ic_optimism_22 + Blockchain.Pepecoin, Blockchain.PepecoinTestnet -> R.drawable.ic_pepecoin_22 + Blockchain.Plasma, Blockchain.PlasmaTestnet -> R.drawable.ic_plasma_22 + Blockchain.Playa3ull -> R.drawable.ic_playa3ull_22 + Blockchain.Polkadot, Blockchain.PolkadotTestnet -> R.drawable.ic_polkadot_16 + Blockchain.Polygon, Blockchain.PolygonTestnet -> R.drawable.ic_polygon_22 + Blockchain.PolygonZkEVM, Blockchain.PolygonZkEVMTestnet -> R.drawable.ic_polygon_22 + Blockchain.PulseChain, Blockchain.PulseChainTestnet -> R.drawable.ic_pls_22 + Blockchain.Quai, Blockchain.QuaiTestnet -> R.drawable.ic_quai_22 + Blockchain.RSK -> R.drawable.ic_rsk_16 + Blockchain.Radiant -> R.drawable.ic_radiant_22 + Blockchain.Ravencoin, Blockchain.RavencoinTestnet -> R.drawable.ic_ravencoin_22 + Blockchain.Scroll, Blockchain.ScrollTestnet -> R.drawable.ic_scroll_22 + Blockchain.Sei, Blockchain.SeiTestnet -> R.drawable.ic_sei_22 + Blockchain.Shibarium, Blockchain.ShibariumTestnet -> R.drawable.ic_shibarium_22 + Blockchain.Solana, Blockchain.SolanaTestnet -> R.drawable.ic_solana_16 + Blockchain.Sonic, Blockchain.SonicTestnet -> R.drawable.ic_sonic_22 + Blockchain.Stellar, Blockchain.StellarTestnet -> R.drawable.ic_stellar_16 + Blockchain.Sui, Blockchain.SuiTestnet -> R.drawable.ic_sui_22 + Blockchain.TON, Blockchain.TONTestnet -> R.drawable.ic_ton_22 + Blockchain.Taraxa, Blockchain.TaraxaTestnet -> R.drawable.ic_taraxa_22 + Blockchain.Telos, Blockchain.TelosTestnet -> R.drawable.ic_telos_22 + Blockchain.TerraV1 -> R.drawable.ic_terra_22 + Blockchain.TerraV2 -> R.drawable.ic_terra2_22 + Blockchain.Tezos -> R.drawable.ic_tezos_16 + Blockchain.Tron, Blockchain.TronTestnet -> R.drawable.ic_tron_22 + Blockchain.VanarChain, Blockchain.VanarChainTestnet -> R.drawable.ic_vanar_22 + Blockchain.VeChain, Blockchain.VeChainTestnet -> R.drawable.ic_vechain_22 + Blockchain.XDC, Blockchain.XDCTestnet -> R.drawable.ic_xdc_22 + Blockchain.XRP -> R.drawable.ic_xrp_22 + Blockchain.Xodex -> R.drawable.ic_xodex_22 + Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet -> R.drawable.ic_zklink_22 + Blockchain.ZkSyncEra, Blockchain.ZkSyncEraTestnet -> R.drawable.ic_zksync_22 + Blockchain.Nexa, Blockchain.NexaTestnet, Blockchain.Unknown -> R.drawable.ic_alert_24 + } + TestModel(blockchain, expected) + } + } + + data class TestModel(val input: Blockchain, val expected: Int) +} \ No newline at end of file From 77a12bcb1394fbff52bc9558f6add12e7e3b8791 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Apr 2026 16:23:08 +0400 Subject: [PATCH 054/206] Updated on 2026-08-14 --- .../swap/choosetoken/api/ChooseTokenBridge.kt | 6 +- .../model/ChooseTokenPortfolioFullBlockUM.kt | 51 +++++++++ .../impl/DefaultChooseTokenBridge.kt | 19 +++- .../converter/ChooseTokenListItemConverter.kt | 2 +- .../impl/model/ChooseTokenModel.kt | 90 ++------------- .../impl/model/PortfolioFullBlockDelegate.kt | 106 ++++++++++++++++++ .../impl/model/PortfolioListBlockDelegate.kt | 4 +- .../choosetoken/impl/ui/ChooseTokenScreen.kt | 61 +++++----- .../swap/choosetoken/impl/ui/ChooseTokenUM.kt | 25 +---- .../swap/converters/TokensDataConverter.kt | 2 +- .../swap/models/SwapSelectTokenStateHolder.kt | 31 +---- .../feature/swap/ui/SwapSelectTokenScreen.kt | 2 +- .../preview/SwapSelectTokenPreviewProvider.kt | 2 +- 13 files changed, 235 insertions(+), 166 deletions(-) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/model/ChooseTokenPortfolioFullBlockUM.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt index 9035ff84e8..ea5546654e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt @@ -9,8 +9,8 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup -import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.presentation.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel @@ -29,6 +29,8 @@ interface ChooseTokenBridge : ChooseTokenBridgeLegacy, ChooseTokenBridgeInternal */ val tokenFilter: MutableStateFlow<(AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean> + fun selectWalletTab(walletId: UserWalletId) + data class Settings( val title: TextReference, val isShowMarketBlock: Boolean, @@ -61,7 +63,7 @@ interface ChooseTokenBridgeInternal { val settings: ChooseTokenBridge.Settings val analyticsPayload: Set val searchQueryState: StateFlow - val portfolioListBlock: Flow> + val fullPortfolioBlock: StateFlow fun onSearchQuery(query: SearchQuery) fun onSearchQuery(query: String) = onSearchQuery(SearchQuery(query)) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/model/ChooseTokenPortfolioFullBlockUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/model/ChooseTokenPortfolioFullBlockUM.kt new file mode 100644 index 0000000000..4611fcdaab --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/model/ChooseTokenPortfolioFullBlockUM.kt @@ -0,0 +1,51 @@ +package com.tangem.feature.swap.choosetoken.api.model + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class ChooseTokenPortfolioFullBlockUM( + val walletList: WalletListUM, + val isBalanceHidden: Boolean, + val isSearching: Boolean, + val tokensListData: TokenListUMData, +) + +data class WalletListUM( + val items: ImmutableList, +) + +data class WalletTabUM( + val text: TextReference, + val count: TextReference?, + val isSelected: Boolean, + val onClick: () -> Unit, +) + +@Immutable +sealed interface TokenListUMData { + + val tokensList: ImmutableList + val totalTokensCount: Int + + data class AccountList( + override val tokensList: ImmutableList, + override val totalTokensCount: Int, + ) : TokenListUMData + + data class TokenList( + override val tokensList: ImmutableList, + override val totalTokensCount: Int, + ) : TokenListUMData + + data object EmptyList : TokenListUMData { + override val tokensList: ImmutableList = persistentListOf() + override val totalTokensCount: Int = EMPTY_TOKENS_COUNT + } + + private companion object { + const val EMPTY_TOKENS_COUNT = 0 + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt index 3b3b7e6611..c4074529a6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt @@ -10,10 +10,11 @@ import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge.Settings import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult import com.tangem.feature.swap.choosetoken.api.ChooseTokenResultOld +import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY +import com.tangem.feature.swap.choosetoken.impl.model.PortfolioFullBlockDelegate import com.tangem.feature.swap.choosetoken.impl.model.PortfolioListBlockDelegate import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup -import com.tangem.feature.swap.models.TokenListUMData import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.* internal class DefaultChooseTokenBridge @AssistedInject constructor( @Assisted private val modelScope: CoroutineScope, portfolioListBlockDelegateFactory: PortfolioListBlockDelegate.Factory, + portfolioFullBlockDelegateFactory: PortfolioFullBlockDelegate.Factory, @Assisted override val settings: Settings, @Assisted override val analyticsPayload: Set, ) : ChooseTokenBridge { @@ -44,10 +46,17 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( searchQueryState = searchQueryState, ) + private val portfolioFullBlockDelegate: PortfolioFullBlockDelegate = portfolioFullBlockDelegateFactory.create( + modelScope = modelScope, + searchQueryState = searchQueryState, + portfolioListBlockDelegate = portfolioListBlockDelegate, + ) + override val tokenFilter: MutableStateFlow<(AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean> get() = portfolioListBlockDelegate.tokenFilter - override val portfolioListBlock: Flow> - get() = portfolioListBlockDelegate.portfolioList + + override val fullPortfolioBlock: StateFlow + get() = portfolioFullBlockDelegate.fullPortfolioBlock private val _currenciesGroupFlow = MutableStateFlow(null) override val currenciesGroup: Flow = _currenciesGroupFlow.filterNotNull() @@ -58,6 +67,10 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( .launchIn(modelScope) } + override fun selectWalletTab(walletId: UserWalletId) { + portfolioFullBlockDelegate.selectWalletTab(walletId) + } + override fun onSearchQuery(query: SearchQuery) { onSearchQuery.trySend(query) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index e8be72f01f..774df42256 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -19,8 +19,8 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData import com.tangem.feature.swap.choosetoken.impl.model.ClickIntents -import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.toPersistentList diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt index 22ee9d1cbf..f03c6bc306 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt @@ -5,27 +5,20 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.AccountId -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.choosetoken.api.* import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarToggleTransformer import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarUpdateQueryTransformer -import com.tangem.feature.swap.choosetoken.impl.ui.* +import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenFullUM +import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM import com.tangem.feature.swap.converters.TokensDataConverter import com.tangem.feature.swap.models.SwapSelectTokenStateHolder import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import javax.inject.Inject @@ -34,7 +27,6 @@ import javax.inject.Inject internal class ChooseTokenModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val settingContextUseCase: SettingContextUseCase, - private val getWalletsUseCase: GetWalletsUseCase, marketBlockDelegateFactory: MarketBlockDelegate.Factory, paramsContainer: ParamsContainer, ) : Model() { @@ -61,25 +53,29 @@ internal class ChooseTokenModel @Inject constructor( } private val expandedAccountsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) - private val onWalletSelected = Channel() val stateOld: StateFlow = combineUIOld() - private val contentState: StateFlow = combineUI() private val initialState: MutableStateFlow = MutableStateFlow(getInitState()) val state: StateFlow = combine( flow = initialState, - flow2 = contentState, - transform = { initial, content -> + flow2 = bridge.fullPortfolioBlock, + flow3 = marketsStateFlow, + transform = { initial, content, marketBlock -> ChooseTokenFullUM( initialUM = initial, - contentUM = content, + portfolioBlock = content, + marketsBlock = marketBlock, ) }, ).stateIn( scope = modelScope, started = SharingStarted.Eagerly, - initialValue = ChooseTokenFullUM(initialState.value, contentState.value), + initialValue = ChooseTokenFullUM( + initialUM = initialState.value, + portfolioBlock = bridge.fullPortfolioBlock.value, + marketsBlock = null, + ), ) init { @@ -143,68 +139,6 @@ internal class ChooseTokenModel @Inject constructor( .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) - @Suppress("LongMethod") - private fun combineUI(): StateFlow = channelFlow { - val allWalletsFlow: StateFlow> = - getWalletsUseCase.invokeAsMap().stateIn(this) - - // todo swap add optional param, store, and GetSelectedWalletUseCase - val firstSelectedWallet = allWalletsFlow.value.values.first() - val selectedWalletFlow: StateFlow = - onWalletSelected.receiveAsFlow() - .mapNotNull { walletId -> allWalletsFlow.value[walletId] } - .stateIn(this, SharingStarted.Eagerly, firstSelectedWallet) - - val fullPortfolioBlockFlow = combine( - flow = allWalletsFlow, - flow2 = bridge.portfolioListBlock, - flow3 = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), - transform = { allWallets, portfolioList, selectedWalletId -> - val tokensListData = portfolioList[selectedWalletId] ?: return@combine null - val walletsUM = allWallets.entries - .map { (walletId, wallet) -> - val searchResultCount: TextReference? = portfolioList[walletId]?.totalTokensCount - ?.toString() - ?.let(::stringReference) - ?.takeIf { isSearchingState } - WalletTabUM( - text = stringReference(wallet.name), - onClick = { onWalletSelected.trySend(walletId) }, - isSelected = selectedWalletId == walletId, - count = searchResultCount, - ) - } - walletsUM to tokensListData - }, - ) - .filterNotNull() - .distinctUntilChanged() - - combine( - flow = fullPortfolioBlockFlow, - flow2 = settingContextUseCase.invoke(), - flow3 = marketsStateFlow, - transform = { (walletList, tokensData), settings, marketsData -> - val walletsUM = if (walletList.size != 1) { - WalletListUM(walletList.toPersistentList()) - } else { - WalletListUM(persistentListOf()) - } - ChooseTokenUM( - walletList = walletsUM, - isBalanceHidden = settings.isBalanceHidden, - isSearching = isSearchingState, - tokensListData = tokensData, - marketsState = marketsData, - ) - }, - ) - .distinctUntilChanged() - .collect { newUM -> channel.send(newUM) } - } - .flowOn(dispatchers.default) - .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) - fun onBackClicked() { bridge.onClose() } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt new file mode 100644 index 0000000000..86ebb93646 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt @@ -0,0 +1,106 @@ +package com.tangem.feature.swap.choosetoken.impl.model + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase +import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.feature.swap.choosetoken.api.model.WalletListUM +import com.tangem.feature.swap.choosetoken.api.model.WalletTabUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* + +@Suppress("LongParameterList") +internal class PortfolioFullBlockDelegate @AssistedInject constructor( + private val settingContextUseCase: SettingContextUseCase, + private val getWalletsUseCase: GetWalletsUseCase, + private val getUserWalletUseCase: GetUserWalletUseCase, + private val dispatchers: CoroutineDispatcherProvider, + private val selectedWalletUseCase: GetSelectedWalletUseCase, + @Assisted private val modelScope: CoroutineScope, + @Assisted private val portfolioListBlockDelegate: PortfolioListBlockDelegate, + @Assisted private val searchQueryState: StateFlow, +) { + + private val isSearchingState: Boolean get() = searchQueryState.isSearchingState + private val onWalletSelected = Channel() + + val selectedWalletFlow: SharedFlow = onWalletSelected.receiveAsFlow() + .distinctUntilChanged() + .mapNotNull { walletId -> getUserWalletUseCase.invoke(walletId).getOrNull() } + .shareIn(modelScope, SharingStarted.Eagerly, replay = 1) + + val fullPortfolioBlock: StateFlow = buildFlow() + .flowOn(dispatchers.default) + .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) + + private fun buildFlow() = flow { + val isNoHaveSelectedWallet = selectedWalletFlow.replayCache.isEmpty() + if (isNoHaveSelectedWallet) { + val firstSelectedWallet = selectedWalletUseCase.sync().getOrNull() + ?: getWalletsUseCase.invokeSync().first() + selectWalletTab(firstSelectedWallet.walletId) + } + + val fullPortfolioBlockFlow = combine( + flow = getWalletsUseCase.invokeAsMap(), + flow2 = portfolioListBlockDelegate.portfolioList, + flow3 = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), + flow4 = settingContextUseCase.invoke(), + transform = { allWallets, portfolioList, selectedWalletId, settings -> + val tokensListData = portfolioList[selectedWalletId] ?: return@combine null + val walletsUM = allWallets.entries + .map { (walletId, wallet) -> + val searchResultCount: TextReference? = portfolioList[walletId]?.totalTokensCount + ?.toString() + ?.let(::stringReference) + ?.takeIf { isSearchingState } + WalletTabUM( + text = stringReference(wallet.name), + onClick = { selectWalletTab(walletId) }, + isSelected = selectedWalletId == walletId, + count = searchResultCount, + ) + } + val walletListUM = if (walletsUM.size != 1) { + WalletListUM(walletsUM.toPersistentList()) + } else { + WalletListUM(persistentListOf()) + } + ChooseTokenPortfolioFullBlockUM( + walletList = walletListUM, + isBalanceHidden = settings.isBalanceHidden, + isSearching = isSearchingState, + tokensListData = tokensListData, + ) + }, + ) + emitAll(fullPortfolioBlockFlow) + } + + fun selectWalletTab(walletId: UserWalletId) { + onWalletSelected.trySend(walletId) + } + + @AssistedFactory + interface Factory { + fun create( + modelScope: CoroutineScope, + portfolioListBlockDelegate: PortfolioListBlockDelegate, + searchQueryState: StateFlow, + ): PortfolioFullBlockDelegate + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt index 948e0763fe..9c932731af 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt @@ -16,8 +16,8 @@ import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQ import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData import com.tangem.feature.swap.choosetoken.impl.converter.ChooseTokenListItemConverter -import com.tangem.feature.swap.models.TokenListUMData import com.tangem.utils.extensions.mapNotNullValues import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -41,7 +41,7 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( val tokenFilter: MutableStateFlow<(AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean> = MutableStateFlow { _, _ -> true } - val portfolioList: Flow> = buildDataFlow() + val portfolioList: SharedFlow> = buildDataFlow() .distinctUntilChanged() .shareIn(modelScope, SharingStarted.Eagerly, replay = 1) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt index 1081546f78..14b9ef03d6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt @@ -44,7 +44,10 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection -import com.tangem.feature.swap.models.TokenListUMData +import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData +import com.tangem.feature.swap.choosetoken.api.model.WalletListUM +import com.tangem.feature.swap.choosetoken.api.model.WalletTabUM import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.ui.market.swapMarketsListItems @@ -56,17 +59,25 @@ import kotlin.random.Random private const val LOAD_MORE_BUFFER = 25 -private val ChooseTokenUM.isNotFoundState: Boolean - get() = tokensListData.tokensList.isEmpty() && - isSearching && - marketsState !is SwapMarketState.Content && - marketsState !is SwapMarketState.Loading +private val ChooseTokenFullUM.isNotFoundState: Boolean + get() { + if (portfolioBlock == null) return false + if (marketsBlock == null) return false + return portfolioBlock.tokensListData.tokensList.isEmpty() && + portfolioBlock.isSearching && + marketsBlock !is SwapMarketState.Content && + marketsBlock !is SwapMarketState.Loading + } -private val ChooseTokenUM.isEmptyState: Boolean - get() = tokensListData.tokensList.isEmpty() && - !isSearching && - marketsState !is SwapMarketState.Content && - marketsState !is SwapMarketState.Loading +private val ChooseTokenFullUM.isEmptyState: Boolean + get() { + if (portfolioBlock == null) return false + if (marketsBlock == null) return false + return portfolioBlock.tokensListData.tokensList.isEmpty() && + !portfolioBlock.isSearching && + marketsBlock !is SwapMarketState.Content && + marketsBlock !is SwapMarketState.Loading + } @Composable internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { @@ -120,27 +131,27 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { assetsTitle() - if (state.contentUM != null) { - walletListItem(state.contentUM.walletList) + if (state.portfolioBlock != null) { + walletListItem(state.portfolioBlock.walletList) when { - state.contentUM.isNotFoundState -> tokensNotFound() - state.contentUM.isEmptyState -> emptyTokensList() + state.isNotFoundState -> tokensNotFound() + state.isEmptyState -> emptyTokensList() else -> { tokensListItems( - tokensListData = state.contentUM.tokensListData, - isBalanceHidden = state.contentUM.isBalanceHidden, + tokensListData = state.portfolioBlock.tokensListData, + isBalanceHidden = state.portfolioBlock.isBalanceHidden, ) - if (state.contentUM.marketsState != null) { + if (state.marketsBlock != null) { item("markets_title_spacer") { SpacerH(height = 20.dp) } - swapMarketsListItems(state.contentUM.marketsState) + swapMarketsListItems(state.marketsBlock) } } } } } - if (state.contentUM?.marketsState != null && !state.contentUM.isNotFoundState && !state.contentUM.isEmptyState) { - SetupMarketScrollTracker(state.contentUM.marketsState, lazyListState) + if (state.marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) { + SetupMarketScrollTracker(state.marketsBlock, lazyListState) } } @@ -460,7 +471,7 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider = sequenceOf( ChooseTokenFullUM( initialUM = initialUM, - contentUM = ChooseTokenUM( + portfolioBlock = ChooseTokenPortfolioFullBlockUM( walletList = WalletListUM(wallets), isBalanceHidden = false, isSearching = false, @@ -468,18 +479,18 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider Unit, val searchBar: SearchBarUM, -) - -internal data class WalletListUM( - val items: ImmutableList, -) - -internal data class WalletTabUM( - val text: TextReference, - val count: TextReference?, - val isSelected: Boolean, - val onClick: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt index d06953f1a3..20fa223b66 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt @@ -6,9 +6,9 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.market.state.SwapMarketState import com.tangem.feature.swap.presentation.R import kotlinx.collections.immutable.persistentListOf diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt index 1c40cf6de6..5582d69682 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt @@ -1,10 +1,7 @@ package com.tangem.feature.swap.models -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData import com.tangem.feature.swap.models.market.state.SwapMarketState -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf internal data class SwapSelectTokenStateHolder( val marketsState: SwapMarketState, @@ -14,32 +11,6 @@ internal data class SwapSelectTokenStateHolder( val onSearchEntered: (String) -> Unit, ) -@Immutable -sealed interface TokenListUMData { - - val tokensList: ImmutableList - val totalTokensCount: Int - - data class AccountList( - override val tokensList: ImmutableList, - override val totalTokensCount: Int, - ) : TokenListUMData - - data class TokenList( - override val tokensList: ImmutableList, - override val totalTokensCount: Int, - ) : TokenListUMData - - data object EmptyList : TokenListUMData { - override val tokensList: ImmutableList = persistentListOf() - override val totalTokensCount: Int = EMPTY_TOKENS_COUNT - } - - private companion object { - const val EMPTY_TOKENS_COUNT = 0 - } -} - internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean get() = tokensListData.tokensList.isEmpty() && isAfterSearch && diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt index 6758ca18b7..4ea3cff4f0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt @@ -39,8 +39,8 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.test.MainScreenTestTags import com.tangem.core.ui.utils.lazyListItemPosition +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.isEmptyState import com.tangem.feature.swap.models.isNotFoundState import com.tangem.feature.swap.models.market.state.SwapMarketState diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt index bb19ee30a7..94da0ebd00 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt @@ -13,8 +13,8 @@ import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.TokenListUMData import com.tangem.feature.swap.models.market.state.SwapMarketState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList From ae09293604fd817d617f768a83dcd85cdb2a0a8b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Apr 2026 17:07:04 +0400 Subject: [PATCH 055/206] Updated on 2026-08-14 --- .../com/tangem/tap/ApplicationEntryPoint.kt | 3 -- .../java/com/tangem/tap/TangemApplication.kt | 8 +----- .../analytics/CustomerIoFeatureToggles.kt | 13 --------- .../pushes/TangemPushNotificationService.kt | 23 ++++----------- ...efaultHoldToConfirmButtonFeatureToggles.kt | 13 --------- .../tap/di/core/ui/CoreUiBindsModule.kt | 7 ----- .../configs/feature_toggles_config.json | 28 ------------------- .../ui/HoldToConfirmButtonFeatureToggles.kt | 5 ---- .../details/model/UserWalletListModel.kt | 4 +-- .../entry/featuretoggle/FeedFeatureToggle.kt | 5 ---- .../feed/di/FeedFeatureToggleModule.kt | 23 --------------- .../featuretoggle/DefaultFeedFeatureToggle.kt | 13 --------- .../feed/model/feed/FeedComponentModel.kt | 12 ++------ .../model/feed/state/FeedStateController.kt | 11 ++------ .../UpdateGlobalFeedStateTransformer.kt | 6 +--- .../hotwallet/HotWalletFeatureToggles.kt | 1 - .../DefaultHotWalletFeatureToggles.kt | 3 -- .../model/AvailableSwapPairsModel.kt | 8 ++---- .../v2/send/confirm/model/SendConfirmModel.kt | 5 +--- .../confirm/model/NFTSendConfirmModel.kt | 5 +--- .../state/StakingStateController.kt | 5 +--- .../confirm/model/SendWithSwapConfirmModel.kt | 5 +--- .../features/swap/SwapFeatureToggles.kt | 4 +-- .../feature/swap/DefaultSwapFeatureToggles.kt | 9 +----- .../feature/swap/di/SwapFeatureModule.kt | 5 ++-- .../tangem/feature/swap/model/SwapModel.kt | 6 +--- .../tangem/feature/swap/ui/StateBuilder.kt | 5 +--- .../featuretoggles/WalletFeatureToggles.kt | 4 --- .../wallet/child/wallet/model/WalletModel.kt | 3 -- .../DefaultWalletFeatureToggles.kt | 6 ---- .../InitializeWalletsTransformer.kt | 15 ++++------ .../converter/WcSendTransactionUMConverter.kt | 5 +--- .../converter/WcSignTransactionUMConverter.kt | 5 +--- .../converter/WcSignTypedDataUMConverter.kt | 5 +--- .../approve/model/YieldSupplyApproveModel.kt | 5 +--- .../model/YieldSupplyStartEarningModel.kt | 5 +--- .../model/YieldSupplyStopEarningModel.kt | 5 +--- 37 files changed, 36 insertions(+), 257 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/common/analytics/CustomerIoFeatureToggles.kt delete mode 100644 app/src/main/java/com/tangem/tap/core/ui/DefaultHoldToConfirmButtonFeatureToggles.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/HoldToConfirmButtonFeatureToggles.kt delete mode 100644 features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt delete mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt delete mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 957e21f3dd..4905038b88 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -43,7 +43,6 @@ import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.hot.sdk.TangemHotSdk -import com.tangem.tap.common.analytics.CustomerIoFeatureToggles import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient import com.tangem.tap.common.log.TangemAppLoggerInitializer @@ -151,7 +150,5 @@ interface ApplicationEntryPoint { fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory - fun getCustomerIoFeatureToggles(): CustomerIoFeatureToggles - fun getScanFailsRequester(): ScanFailsRequester } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index e03df1316b..39c1f273db 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -60,7 +60,6 @@ import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.operations.attestation.api.TangemApiServiceSettings import com.tangem.tap.common.analytics.AnalyticsFactory -import com.tangem.tap.common.analytics.CustomerIoFeatureToggles import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.amplitude.AmplitudeAnalyticsHandler @@ -236,9 +235,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val appsFlyerClientFactory: AppsFlyerClient.Factory get() = entryPoint.getAppsFlyerClientFactory() - private val customerIoFeatureToggles: CustomerIoFeatureToggles - get() = entryPoint.getCustomerIoFeatureToggles() - private val scanFailsRequester get() = entryPoint.getScanFailsRequester() @@ -415,9 +411,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. factory.addHandlerBuilder(FirebaseAnalyticsHandler.Builder()) factory.addHandlerBuilder(AppsFlyerAnalyticsHandler.Builder(appsFlyerClientFactory)) - if (customerIoFeatureToggles.isFeatureEnabled) { - factory.addHandlerBuilder(CustomerIoAnalyticsHandler.Builder()) - } + factory.addHandlerBuilder(CustomerIoAnalyticsHandler.Builder()) factory.addFilter(oneTimeEventFilter) factory.addFilter(AppsFlyerEventFilter()) diff --git a/app/src/main/java/com/tangem/tap/common/analytics/CustomerIoFeatureToggles.kt b/app/src/main/java/com/tangem/tap/common/analytics/CustomerIoFeatureToggles.kt deleted file mode 100644 index c847d03c9f..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/CustomerIoFeatureToggles.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.common.analytics - -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import javax.inject.Inject - -class CustomerIoFeatureToggles @Inject constructor( - private val featureTogglesManager: FeatureTogglesManager, -) { - - val isFeatureEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.CUSTOMER_IO_ENABLED) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt index ba6b3ddcfe..aef6c2946b 100644 --- a/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt +++ b/app/src/main/java/com/tangem/tap/common/pushes/TangemPushNotificationService.kt @@ -3,19 +3,12 @@ package com.tangem.tap.common.pushes import android.annotation.SuppressLint import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import com.tangem.tap.common.analytics.CustomerIoFeatureToggles import com.tangem.utils.logging.TangemLogger -import dagger.hilt.android.AndroidEntryPoint import io.customer.messagingpush.CustomerIOFirebaseMessagingService -import javax.inject.Inject -@AndroidEntryPoint @SuppressLint("MissingFirebaseInstanceTokenRefresh") internal class TangemPushNotificationService : FirebaseMessagingService() { - @Inject - lateinit var customerIoFeatureToggles: CustomerIoFeatureToggles - private val pushNotificationDelegate: PushNotificationDelegate by lazy { PushNotificationDelegate(applicationContext) } @@ -24,21 +17,17 @@ internal class TangemPushNotificationService : FirebaseMessagingService() { super.onNewToken(token) TangemLogger.d("New FCM token received: $token") - if (customerIoFeatureToggles.isFeatureEnabled) { - CustomerIOFirebaseMessagingService.onNewToken(applicationContext, token) - } + CustomerIOFirebaseMessagingService.onNewToken(applicationContext, token) } override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) - if (customerIoFeatureToggles.isFeatureEnabled) { - CustomerIOFirebaseMessagingService.onMessageReceived( - context = applicationContext, - remoteMessage = message, - handleNotificationTrigger = false, - ) - } + CustomerIOFirebaseMessagingService.onMessageReceived( + context = applicationContext, + remoteMessage = message, + handleNotificationTrigger = false, + ) val notification = message.notification ?: return val channelId = notification.channelId ?: TANGEM_CHANNEL_ID diff --git a/app/src/main/java/com/tangem/tap/core/ui/DefaultHoldToConfirmButtonFeatureToggles.kt b/app/src/main/java/com/tangem/tap/core/ui/DefaultHoldToConfirmButtonFeatureToggles.kt deleted file mode 100644 index 1508daf99e..0000000000 --- a/app/src/main/java/com/tangem/tap/core/ui/DefaultHoldToConfirmButtonFeatureToggles.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.core.ui - -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles -import javax.inject.Inject - -class DefaultHoldToConfirmButtonFeatureToggles @Inject constructor( - private val featureTogglesManager: FeatureTogglesManager, -) : HoldToConfirmButtonFeatureToggles { - override val isHoldToConfirmEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.HOLD_TO_CONFIRM_BUTTON_ENABLED) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt b/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt index 47e1215497..b02028c5bf 100644 --- a/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/core/ui/CoreUiBindsModule.kt @@ -1,9 +1,7 @@ package com.tangem.tap.di.core.ui import com.tangem.core.ui.DesignFeatureToggles -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.tap.core.ui.DefaultDesignFeatureToggles -import com.tangem.tap.core.ui.DefaultHoldToConfirmButtonFeatureToggles import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -15,9 +13,4 @@ interface CoreUiBindsModule { @Binds fun bindDesignFeatureToggles(impl: DefaultDesignFeatureToggles): DesignFeatureToggles - - @Binds - fun bindHoldToConfirmButtonFeatureToggles( - impl: DefaultHoldToConfirmButtonFeatureToggles, - ): HoldToConfirmButtonFeatureToggles } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index b9a16cd745..214bc42fee 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -20,30 +20,10 @@ "name": "SWAP_REDESIGN_ENABLED", "version": "undefined" }, - { - "name": "HOT_WALLET_CREATION_RESTRICTION_ENABLED", - "version": "5.32.0" - }, { "name": "APP_REDESIGN_ENABLED", "version": "undefined" }, - { - "name": "SWAP_MARKET_LIST_ENABLED", - "version": "5.34" - }, - { - "name": "EARN_BLOCK_ENABLED", - "version": "5.35" - }, - { - "name": "HOLD_TO_CONFIRM_BUTTON_ENABLED", - "version": "5.35" - }, - { - "name": "WALLET_REORDER_FEATURE_ENABLED", - "version": "5.34" - }, { "name": "GASLESS_APPROVAL_ENABLED", "version": "5.37" @@ -56,14 +36,6 @@ "name": "DYNAMIC_ADDRESSES_ENABLED", "version": "undefined" }, - { - "name": "CUSTOMER_IO_ENABLED", - "version": "5.35" - }, - { - "name": "MAIN_SCREEN_QR_SCANNING_ENABLED", - "version": "5.36" - }, { "name": "NEW_PROMO_BANNERS_ENABLED", "version": "5.37" diff --git a/core/ui/src/main/java/com/tangem/core/ui/HoldToConfirmButtonFeatureToggles.kt b/core/ui/src/main/java/com/tangem/core/ui/HoldToConfirmButtonFeatureToggles.kt deleted file mode 100644 index 35efae6bc9..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/HoldToConfirmButtonFeatureToggles.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.core.ui - -interface HoldToConfirmButtonFeatureToggles { - val isHoldToConfirmEnabled: Boolean -} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index dfa422bab2..ac74369033 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -21,7 +21,6 @@ import com.tangem.features.details.entity.WalletReorderUM import com.tangem.features.details.impl.R import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.features.details.utils.UserWalletSaver -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList @@ -43,7 +42,6 @@ internal class UserWalletListModel @Inject constructor( private val hotWalletRestrictionManager: HotWalletRestrictionManager, private val unlockWalletUseCase: UnlockWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, - private val walletFeatureToggles: WalletFeatureToggles, private val applyUserWalletListSortingUseCase: ApplyUserWalletListSortingUseCase, ) : Model() { @@ -93,7 +91,7 @@ internal class UserWalletListModel @Inject constructor( isWalletSavingInProgress = isWalletSavingInProgress, addNewWalletText = resourceReference(R.string.user_wallet_list_add_button), walletReorderUM = WalletReorderUM( - isDragEnabled = walletFeatureToggles.isWalletReorderFeatureEnabled && userWallets.size > 1, + isDragEnabled = userWallets.size > 1, onMove = ::onWalletReorder, onDragStopped = ::onWalletDragStopped, ), diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt deleted file mode 100644 index 1513ec7932..0000000000 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/featuretoggle/FeedFeatureToggle.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.feed.entry.featuretoggle - -interface FeedFeatureToggle { - val isEarnBlockEnabled: Boolean -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt deleted file mode 100644 index 7e9bee8655..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/di/FeedFeatureToggleModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.features.feed.di - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle -import com.tangem.features.feed.featuretoggle.DefaultFeedFeatureToggle -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object FeedFeatureToggleModule { - - @Provides - @Singleton - fun provideFeedFeatureToggle(featureTogglesManager: FeatureTogglesManager): FeedFeatureToggle { - return DefaultFeedFeatureToggle( - featureTogglesManager = featureTogglesManager, - ) - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt deleted file mode 100644 index 025dd02c28..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/featuretoggle/DefaultFeedFeatureToggle.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.features.feed.featuretoggle - -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle - -internal class DefaultFeedFeatureToggle( - private val featureTogglesManager: FeatureTogglesManager, -) : FeedFeatureToggle { - - override val isEarnBlockEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.EARN_BLOCK_ENABLED) -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 170b29bdae..c3052275b3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -32,7 +32,6 @@ import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent @@ -63,7 +62,6 @@ internal class FeedComponentModel @Inject constructor( private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val stateController: FeedStateController, - private val feedFeatureToggle: FeedFeatureToggle, private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase, private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase, private val appRouter: AppRouter, @@ -147,7 +145,6 @@ internal class FeedComponentModel @Inject constructor( } }, analyticsEventHandler = analyticsEventHandler, - feedFeatureToggle = feedFeatureToggle, ) val currentState = stateController.value @@ -171,7 +168,7 @@ internal class FeedComponentModel @Inject constructor( analyticsEventHandler = analyticsEventHandler, ), UpdateEarnStateTransformer( - isEarnEnabled = feedFeatureToggle.isEarnBlockEnabled, + isEarnEnabled = true, onItemClick = ::handleEarnTokenClick, onRetryClick = ::fetchEarnData, earnResult = earnResult, @@ -206,7 +203,6 @@ internal class FeedComponentModel @Inject constructor( } private fun fetchEarnData() { - if (!feedFeatureToggle.isEarnBlockEnabled) return modelScope.launch(dispatchers.default) { stateController.update(UpdateEarnLoadingStateTransformer()) fetchTopEarnTokensUseCase() @@ -280,11 +276,7 @@ internal class FeedComponentModel @Inject constructor( currentSortByType = SortByTypeUM.TopGainers, ), globalState = GlobalFeedState.Loading, - earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) { - EarnListUM.Loading - } else { - EarnListUM.Empty - }, + earnListUM = EarnListUM.Loading, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt index 7100cff366..73fe72d6b2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt @@ -2,7 +2,6 @@ package com.tangem.features.feed.model.feed.state import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.feed.model.feed.state.transformers.FeedListUMTransformer import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.earn.state.EarnListUM @@ -16,9 +15,7 @@ import kotlinx.coroutines.flow.update import javax.inject.Inject @ModelScoped -internal class FeedStateController @Inject constructor( - private val feedFeatureToggle: FeedFeatureToggle, -) { +internal class FeedStateController @Inject constructor() { private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) @@ -67,11 +64,7 @@ internal class FeedStateController @Inject constructor( currentSortByType = SortByTypeUM.Trending, ), globalState = GlobalFeedState.Loading, - earnListUM = if (feedFeatureToggle.isEarnBlockEnabled) { - EarnListUM.Loading - } else { - EarnListUM.Empty - }, + earnListUM = EarnListUM.Loading, ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt index 56d9d140a4..ef14cf08bc 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/transformers/UpdateGlobalFeedStateTransformer.kt @@ -3,7 +3,6 @@ package com.tangem.features.feed.model.feed.state.transformers import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.models.earn.EarnTopToken import com.tangem.domain.models.news.TrendingNews -import com.tangem.features.feed.entry.featuretoggle.FeedFeatureToggle import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.earn.state.EarnListUM @@ -19,16 +18,13 @@ internal class UpdateGlobalFeedStateTransformer( private val earnResult: EarnTopToken?, private val onRetryClicked: () -> Unit, private val analyticsEventHandler: AnalyticsEventHandler, - private val feedFeatureToggle: FeedFeatureToggle, ) : FeedListUMTransformer { override fun transform(prevState: FeedListUM): FeedListUM { val blockStates = buildList { add(getNewsState(prevState)) add(getChartsState()) - if (feedFeatureToggle.isEarnBlockEnabled) { - add(getEarnState(prevState)) - } + add(getEarnState(prevState)) } val newGlobalState = when { diff --git a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt index aeb22534d7..373418d3b3 100644 --- a/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt +++ b/features/hot-wallet/api/src/main/kotlin/com/tangem/features/hotwallet/HotWalletFeatureToggles.kt @@ -1,6 +1,5 @@ package com.tangem.features.hotwallet interface HotWalletFeatureToggles { - val isWalletCreationRestrictionEnabled: Boolean val isAssetsDiscoveryEnabled: Boolean } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt index bff219fd75..9c0d680f33 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/DefaultHotWalletFeatureToggles.kt @@ -7,9 +7,6 @@ internal class DefaultHotWalletFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, ) : HotWalletFeatureToggles { - override val isWalletCreationRestrictionEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.HOT_WALLET_CREATION_RESTRICTION_ENABLED) - override val isAssetsDiscoveryEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.ASSETS_DISCOVERY_ENABLED) } \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt index 3068b62dcf..05c0c3bddb 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt @@ -61,7 +61,6 @@ import com.tangem.features.onramp.utils.ClearSearchBarTransformer import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer -import com.tangem.features.swap.SwapFeatureToggles import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -90,7 +89,6 @@ internal class AvailableSwapPairsModel @Inject constructor( private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val excludedBlockchains: ExcludedBlockchains, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, - swapFeatureToggles: SwapFeatureToggles, getWalletsUseCase: GetWalletsUseCase, ) : Model() { @@ -150,10 +148,8 @@ internal class AvailableSwapPairsModel @Inject constructor( subscribeOnSelectedStatusChange() subscribeOnAvailablePairsUpdates() - if (swapFeatureToggles.isMarketListFeatureEnabled) { - subscribeOnMarketsUpdates() - subscribeOnVisibleMarketItems() - } + subscribeOnMarketsUpdates() + subscribeOnVisibleMarketItems() addToPortfolioManager.onDismiss.receiveAsFlow() .onEach { bottomSheetNavigation.dismiss() } .launchIn(modelScope) diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt index e201062981..1a5ca89baa 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/confirm/model/SendConfirmModel.kt @@ -21,7 +21,6 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -114,7 +113,6 @@ internal class SendConfirmModel @Inject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val currenciesRepository: CurrenciesRepository, private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -586,8 +584,7 @@ internal class SendConfirmModel @Inject constructor( val confirmUM = uiState.value.confirmUM val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending - val isHoldToConfirm = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - userWallet.isHotWallet && isContent + val isHoldToConfirm = userWallet.isHotWallet && isContent return NavigationButton( textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm), iconRes = walletInterationIcon(userWallet), diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt index 15754e5e6a..a707e8b5c1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -16,7 +16,6 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -91,7 +90,6 @@ internal class NFTSendConfirmModel @Inject constructor( private val nftSendAnalyticHelper: NFTSendAnalyticHelper, private val nftSendSuccessTrigger: NFTSendSuccessTrigger, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), NFTSendConfirmClickIntents, SendNotificationsComponent.ModelCallback, FeeSelectorModelCallback { @@ -424,8 +422,7 @@ internal class NFTSendConfirmModel @Inject constructor( val confirmUM = uiState.value.confirmUM val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending - val isHoldToConfirm = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - userWallet.isHotWallet && isContent + val isHoldToConfirm = userWallet.isHotWallet && isContent return NavigationButton( textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm), iconRes = walletInterationIcon(userWallet), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt index 32844ee371..3e77d49d36 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/StakingStateController.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import com.tangem.core.decompose.di.ModelScoped -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.domain.models.wallet.isColdWallet import com.tangem.domain.models.wallet.isHotWallet import javax.inject.Inject @@ -23,7 +22,6 @@ import javax.inject.Inject @ModelScoped internal class StakingStateController @Inject constructor( urlOpener: UrlOpener, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) { private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) @@ -39,8 +37,7 @@ internal class StakingStateController @Inject constructor( mutableUiState.update { state -> state.copy( isColdWalletInteractionIconVisible = userWallet.isColdWallet, - shouldShowHoldToConfirmButton = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - userWallet.isHotWallet, + shouldShowHoldToConfirmButton = userWallet.isHotWallet, ) } } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 3c71e14050..a73337555b 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -18,7 +18,6 @@ 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.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCase @@ -101,7 +100,6 @@ internal class SendWithSwapConfirmModel @Inject constructor( private val swapAlertFactory: SwapAlertFactory, private val appRouter: AppRouter, private val analyticsEventHandler: AnalyticsEventHandler, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, swapTransactionSenderFactory: SwapTransactionSender.Factory, paramsContainer: ParamsContainer, ) : Model(), FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -571,8 +569,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( val confirmUM = state.confirmUM val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isTransactionInProcess - val isHoldToConfirm = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - params.userWallet.isHotWallet && isContent + val isHoldToConfirm = params.userWallet.isHotWallet && isContent params.callback.onResult( route = SendWithSwapRoute.Confirm, sendWithSwapUM = state.copy( diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index d782e276ca..e0fe076fab 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -1,5 +1,3 @@ package com.tangem.features.swap -interface SwapFeatureToggles { - val isMarketListFeatureEnabled: Boolean -} \ No newline at end of file +interface SwapFeatureToggles \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index d02dc9c000..c202111fb6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -1,12 +1,5 @@ package com.tangem.feature.swap -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.swap.SwapFeatureToggles -internal class DefaultSwapFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : SwapFeatureToggles { - override val isMarketListFeatureEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.SWAP_MARKET_LIST_ENABLED) -} \ No newline at end of file +internal class DefaultSwapFeatureToggles : SwapFeatureToggles \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt index 5cf4ea502f..990f8c8ec9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/di/SwapFeatureModule.kt @@ -1,6 +1,5 @@ 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 @@ -18,8 +17,8 @@ internal object SwapFeatureModule { @Provides @Singleton - fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles { - return DefaultSwapFeatureToggles(featureTogglesManager) + fun provideSwapFeatureToggles(): SwapFeatureToggles { + return DefaultSwapFeatureToggles() } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index eb2222f685..a369c9494d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -25,7 +25,6 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.R import com.tangem.core.ui.extensions.* import com.tangem.core.ui.message.DialogMessage @@ -146,7 +145,6 @@ internal class SwapModel @Inject constructor( private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, private val appsFlyerStore: AppsFlyerStore, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, private val messageSender: UiMessageSender, private val paymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, private val allowPermissionsHandler: AllowPermissionsHandler, @@ -169,8 +167,7 @@ internal class SwapModel @Inject constructor( } private val swapInteractor = swapInteractorFactory.create(userWalletId) - val isHoldToConfirmEnabled: Boolean = - holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && userWallet.isHotWallet + val isHoldToConfirmEnabled: Boolean = userWallet.isHotWallet private lateinit var initialFromStatus: CryptoCurrencyStatus private var initialToStatus: CryptoCurrencyStatus? = null @@ -195,7 +192,6 @@ internal class SwapModel @Inject constructor( appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), isAccountsModeProvider = Provider { isAccountsMode }, iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, - holdToConfirmButtonFeatureToggles = holdToConfirmButtonFeatureToggles, ) private val inputNumberFormatter = diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 7df83970a1..9fdab1593d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -10,7 +10,6 @@ import com.tangem.common.ui.account.toUM import com.tangem.common.ui.bottomsheet.permission.state.* import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.* @@ -59,11 +58,9 @@ internal class StateBuilder( private val appCurrencyProvider: Provider, private val isAccountsModeProvider: Provider, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, - holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) { - private val isHoldToConfirmEnabled: Boolean = - holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && userWalletProvider().isHotWallet + private val isHoldToConfirmEnabled: Boolean = userWalletProvider().isHotWallet private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt index 1b13fbb3ad..f4d741b7fe 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt @@ -7,9 +7,5 @@ package com.tangem.features.wallet.featuretoggles */ interface WalletFeatureToggles { - val isWalletReorderFeatureEnabled: Boolean - - val isMainScreenQrScanningEnabled: Boolean - val isAddAndManageTokensEnabled: Boolean } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index b1e648a65f..828ab47a4f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -65,7 +65,6 @@ import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.coroutines.* import kotlinx.coroutines.* @@ -118,7 +117,6 @@ internal class WalletModel @Inject constructor( private val appsFlyerStore: AppsFlyerStore, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getWalletIconUseCase: GetWalletIconUseCase, - private val walletFeatureToggles: WalletFeatureToggles, private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val wcPairService: WcPairService, private val resolveQrSendTargetsUseCase: ResolveQrSendTargetsUseCase, @@ -552,7 +550,6 @@ internal class WalletModel @Inject constructor( wallets = action.wallets, clickIntents = clickIntents, walletImageResolver = walletImageResolver, - isMainScreenQrScanningEnabled = walletFeatureToggles.isMainScreenQrScanningEnabled, getWalletIconUseCase = getWalletIconUseCase, isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt index 0120f51987..a15503fa38 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt @@ -9,12 +9,6 @@ internal class DefaultWalletFeatureToggles @Inject constructor( private val featureToggles: FeatureTogglesManager, ) : WalletFeatureToggles { - override val isWalletReorderFeatureEnabled: Boolean - get() = featureToggles.isFeatureEnabled(FeatureToggles.WALLET_REORDER_FEATURE_ENABLED) - - override val isMainScreenQrScanningEnabled: Boolean - get() = featureToggles.isFeatureEnabled(FeatureToggles.MAIN_SCREEN_QR_SCANNING_ENABLED) - override val isAddAndManageTokensEnabled: Boolean get() = featureToggles.isFeatureEnabled(FeatureToggles.ADD_AND_MANAGE_TOKENS_ENABLED) } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt index 4c669daeed..fe74c26c2b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt @@ -27,7 +27,6 @@ internal class InitializeWalletsTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isMainScreenQrScanningEnabled: Boolean = false, private val isTangemPayRefactorEnabled: Boolean, ) : WalletScreenStateTransformer { @@ -65,15 +64,11 @@ internal class InitializeWalletsTransformer( private fun createTopBarConfig(): WalletTopBarConfig { return WalletTopBarConfig( - endActions = listOfNotNull( - if (isMainScreenQrScanningEnabled) { - TangemTopBarActionUM( - iconRes = CoreUiR.drawable.ic_qrcode_scaner_24, - onClick = clickIntents::onScanQrClick, - ) - } else { - null - }, + endActions = listOf( + TangemTopBarActionUM( + iconRes = CoreUiR.drawable.ic_qrcode_scaner_24, + onClick = clickIntents::onScanQrClick, + ), TangemTopBarActionUM( iconRes = CoreUiR.drawable.ic_more_default_24, onClick = clickIntents::onDetailsClick, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt index 6976bac3a3..e4969a134c 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt @@ -16,7 +16,6 @@ import com.tangem.features.walletconnect.transaction.entity.common.WcTransaction import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransactionItemUM import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransactionUM import com.tangem.features.walletconnect.utils.WcNotificationsFactory -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.domain.models.wallet.isHotWallet import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList @@ -27,7 +26,6 @@ internal class WcSendTransactionUMConverter @Inject constructor( private val networkInfoUMConverter: WcNetworkInfoUMConverter, private val requestBlockUMConverter: WcTransactionRequestBlockUMConverter, private val notificationsFactory: WcNotificationsFactory, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Converter { override fun convert(value: Input): WcSendTransactionUM? { @@ -65,8 +63,7 @@ internal class WcSendTransactionUMConverter @Inject constructor( } }, feeErrorNotification = feeErrorNotification, - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - value.context.session.wallet.isHotWallet, + isHoldToConfirmEnabled = value.context.session.wallet.isHotWallet, ), feeSelectorUM = when (value.feeState) { WcTransactionFeeState.None -> FeeSelectorUM.Loading diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt index fcccd0f7f8..4fd75570a0 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTransactionUMConverter.kt @@ -10,7 +10,6 @@ import com.tangem.features.walletconnect.transaction.entity.common.WcTransaction import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoUM import com.tangem.features.walletconnect.transaction.entity.sign.WcSignTransactionItemUM import com.tangem.features.walletconnect.transaction.entity.sign.WcSignTransactionUM -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.domain.models.wallet.isHotWallet import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList @@ -20,7 +19,6 @@ internal class WcSignTransactionUMConverter @Inject constructor( private val appInfoContentUMConverter: WcTransactionAppInfoContentUMConverter, private val networkInfoUMConverter: WcNetworkInfoUMConverter, private val requestBlockUMConverter: WcTransactionRequestBlockUMConverter, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Converter { override fun convert(value: Input) = WcSignTransactionUM( @@ -38,8 +36,7 @@ internal class WcSignTransactionUMConverter @Inject constructor( isLoading = value.signState.domainStep == WcSignStep.Signing, address = WcAddressConverter.convert(value.context.derivationState), walletInteractionIcon = walletInterationIcon(value.context.session.wallet), - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - value.context.session.wallet.isHotWallet, + isHoldToConfirmEnabled = value.context.session.wallet.isHotWallet, ), transactionRequestInfo = WcTransactionRequestInfoUM( requestBlockUMConverter.convert( diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt index 5a8f8d3fc3..6f185ecfd9 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSignTypedDataUMConverter.kt @@ -10,7 +10,6 @@ import com.tangem.features.walletconnect.transaction.entity.common.WcTransaction import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoUM import com.tangem.features.walletconnect.transaction.entity.sign.WcSignTransactionItemUM import com.tangem.features.walletconnect.transaction.entity.sign.WcSignTransactionUM -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.domain.models.wallet.isHotWallet import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList @@ -20,7 +19,6 @@ internal class WcSignTypedDataUMConverter @Inject constructor( private val appInfoContentUMConverter: WcTransactionAppInfoContentUMConverter, private val networkInfoUMConverter: WcNetworkInfoUMConverter, private val requestBlockUMConverter: WcTransactionRequestBlockUMConverter, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Converter { override fun convert(value: Input): WcSignTransactionUM = WcSignTransactionUM( @@ -38,8 +36,7 @@ internal class WcSignTypedDataUMConverter @Inject constructor( address = WcAddressConverter.convert(value.context.derivationState), isLoading = value.signState.domainStep == WcSignStep.Signing, walletInteractionIcon = walletInterationIcon(value.context.session.wallet), - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - value.context.session.wallet.isHotWallet, + isHoldToConfirmEnabled = value.context.session.wallet.isHotWallet, ), transactionRequestInfo = WcTransactionRequestInfoUM( blocks = buildList { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt index a4b555063f..78d87c1dbb 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt @@ -11,7 +11,6 @@ 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.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference @@ -65,7 +64,6 @@ internal class YieldSupplyApproveModel @Inject constructor( private val yieldSupplyGetContractAddressUseCase: YieldSupplyGetContractAddressUseCase, private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, private val yieldSupplyAlertFactory: YieldSupplyAlertFactory, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyApproveComponent.Params = paramsContainer.require() @@ -102,8 +100,7 @@ internal class YieldSupplyApproveModel @Inject constructor( yieldSupplyFeeUM = YieldSupplyFeeUM.Loading, isPrimaryButtonEnabled = false, isTransactionSending = false, - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - params.userWallet.isHotWallet, + isHoldToConfirmEnabled = params.userWallet.isHotWallet, ), ) diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index 4824105018..345179d5ed 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -9,7 +9,6 @@ import com.tangem.core.analytics.models.Basic import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList @@ -71,7 +70,6 @@ internal class YieldSupplyStartEarningModel @Inject constructor( private val yieldSupplyRepository: YieldSupplyRepository, private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, private val appsFlyerStore: AppsFlyerStore, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStartEarningComponent.Params = paramsContainer.require() @@ -317,8 +315,7 @@ internal class YieldSupplyStartEarningModel @Inject constructor( userWallet = wallet uiState.update { it.copy( - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - wallet.isHotWallet, + isHoldToConfirmEnabled = wallet.isHotWallet, ) } getCurrenciesStatusUpdates() diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index 2d62921de0..2c6c834887 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -8,7 +8,6 @@ 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.navigation.url.UrlOpener -import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList @@ -67,7 +66,6 @@ internal class YieldSupplyStopEarningModel @Inject constructor( private val yieldSupplyRepository: YieldSupplyRepository, private val yieldSupplyPendingTracker: YieldSupplyPendingTracker, private val appsFlyerStore: AppsFlyerStore, - private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles, ) : Model(), YieldSupplyNotificationsComponent.ModelCallback { private val params: YieldSupplyStopEarningComponent.Params = paramsContainer.require() @@ -104,8 +102,7 @@ internal class YieldSupplyStopEarningModel @Inject constructor( yieldSupplyFeeUM = YieldSupplyFeeUM.Loading, isPrimaryButtonEnabled = false, isTransactionSending = false, - isHoldToConfirmEnabled = holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && - params.userWallet.isHotWallet, + isHoldToConfirmEnabled = params.userWallet.isHotWallet, ), ) From 0f700c4071e1be326ed148e6369c0cce70758101 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Apr 2026 14:05:53 +0100 Subject: [PATCH 056/206] Updated on 2026-08-14 --- features/staking/impl/build.gradle.kts | 11 + .../impl/presentation/model/StakingModel.kt | 15 +- .../presentation/model/StakingModelTest.kt | 2247 +++++++++++++++++ 3 files changed, 2266 insertions(+), 7 deletions(-) create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTest.kt diff --git a/features/staking/impl/build.gradle.kts b/features/staking/impl/build.gradle.kts index f2d9d8b1cf..218c739822 100644 --- a/features/staking/impl/build.gradle.kts +++ b/features/staking/impl/build.gradle.kts @@ -10,6 +10,10 @@ android { namespace = "com.tangem.features.staking.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** AndroidX */ implementation(deps.androidx.fragment.ktx) @@ -88,4 +92,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) } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index 4697e51e82..04f821f568 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -453,10 +453,9 @@ internal class StakingModel @Inject constructor( stakingAnalyticSender.sendTransactionStakingClickedAnalytics(value) stateController.update(SetConfirmationStateInProgressTransformer()) - if (integration is P2PEthPoolIntegration) { - checkFeeAndSendP2PTransaction() - } else { - sendTransaction() + when (integration) { + is P2PEthPoolIntegration -> checkFeeAndSendP2PTransaction() + is StakeKitIntegration -> sendTransaction() } }.saveIn(sendTransactionJobHolder) } @@ -952,7 +951,7 @@ internal class StakingModel @Inject constructor( reduceAmountByDiff: BigDecimal, notification: Class, ) { - AmountReduceByStateTransformer( + val transformer = AmountReduceByStateTransformer( cryptoCurrencyStatus = cryptoCurrencyStatus, minimumTransactionAmount = minimumTransactionAmount, value = ReduceByData( @@ -960,6 +959,7 @@ internal class StakingModel @Inject constructor( reduceAmountByDiff = reduceAmountByDiff, ), ) + stateController.update(transformer) onNotificationCancel(notification) } @@ -1057,8 +1057,9 @@ internal class StakingModel @Inject constructor( modelScope.launch { val network = cryptoCurrencyStatus.currency.network - val metaInfo = - getWalletMetaInfoUseCase(userWallet.walletId).getOrElse { error("CardInfo must be not null") } + val metaInfo = getWalletMetaInfoUseCase(userWalletId = userWallet.walletId).getOrElse { + error("CardInfo must be not null") + } val amountState = uiState.value.amountState as? AmountState.Data val confirmationState = uiState.value.confirmationState as? StakingStates.ConfirmationState.Data val validatorState = uiState.value.validatorState as? StakingStates.ValidatorState.Data diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTest.kt new file mode 100644 index 0000000000..547b0cb61e --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTest.kt @@ -0,0 +1,2247 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +import arrow.core.right +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.api.ParamsInterceptorHolder +import com.tangem.core.analytics.models.Basic +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.haptic.VibratorHapticManager +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.BalanceHidingSettings +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.feedback.models.FeedbackEmailType +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.staking.* +import com.tangem.domain.models.staking.action.StakingActionType +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.staking.* +import com.tangem.domain.staking.analytics.StakeScreenSource +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.StakingTarget +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.model.stakekit.action.StakingAction +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.tokens.* +import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.approval.api.GiveApprovalFeatureToggles +import com.tangem.features.staking.api.StakingComponent +import com.tangem.features.staking.impl.navigation.InnerStakingRouter +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType +import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater +import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory +import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender +import com.tangem.features.staking.impl.presentation.state.transformers.* +import com.tangem.features.staking.impl.presentation.state.transformers.amount.* +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetInProgressTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetTypeChangeTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.ShowApprovalBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.notifications.DismissStakingNotificationsStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.CompleteInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeErrorToTonInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeToTonInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.ShowTonInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +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.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelTest { + + private val testUserWalletId = UserWalletId("1234567890ABCDEF") + private val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) + private val testIntegrationId = StakingIntegrationID.StakeKit.Coin.Solana + private val testParams = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = testIntegrationId, + ) + private val testYield: Yield = mockk(relaxed = true) + private val testUserWallet: UserWallet = mockk(relaxed = true) + private val initialUiState: StakingUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.InitialInfo + } + + private lateinit var testCryptoCurrencyStatus: CryptoCurrencyStatus + private lateinit var testAccountCurrencyStatus: AccountCryptoCurrencyStatus + private lateinit var mockBalanceUpdater: StakingBalanceUpdater + + private val stateController: StakingStateController = mockk() + private val getYieldUseCase: GetYieldUseCase = mockk() + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val getUserWalletUseCase: GetUserWalletUseCase = mockk() + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val appRouter: AppRouter = mockk() + + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk() + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk() + private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase = mockk() + private val sendTransactionUseCase: SendTransactionUseCase = mockk() + private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk() + private val getAllowanceUseCase: GetAllowanceUseCase = mockk() + private val vibratorHapticManager: VibratorHapticManager = mockk() + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk() + private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk() + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk() + private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() + private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() + private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase = mockk() + private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase = mockk() + private val stakingOperationsFactory: StakingOperationsFactory = mockk() + private val stakingBalanceUpdater: StakingBalanceUpdater.Factory = mockk() + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk() + private val getActionsUseCase: GetActionsUseCase = mockk() + private val p2pEthPoolRepository: P2PEthPoolRepository = mockk() + private val checkAccountInitializedUseCase: CheckAccountInitializedUseCase = mockk() + private val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk() + private val getFeeUseCase: GetFeeUseCase = mockk() + private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase = mockk() + private val getActionRequirementAmountUseCase: GetActionRequirementAmountUseCase = mockk() + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() + private val paramsInterceptorHolder: ParamsInterceptorHolder = mockk(relaxed = true) + private val shareManager: ShareManager = mockk() + private val urlOpener: UrlOpener = mockk() + private val coroutineScope: AppCoroutineScope = mockk() + private val innerRouter: InnerStakingRouter = mockk() + private val messageSender: UiMessageSender = mockk() + private val giveApprovalFeatureToggles: GiveApprovalFeatureToggles = mockk() + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + + val (status, accountStatus) = createMockedAccountCurrencyStatus() + testCryptoCurrencyStatus = status + testAccountCurrencyStatus = accountStatus + + coEvery { getYieldUseCase(testIntegrationId.value) } returns Either.Right(testYield) + every { getSelectedAppCurrencyUseCase() } returns flowOf(Either.Right(AppCurrency.Default)) + every { stateController.uiState } returns MutableStateFlow(initialUiState) + every { stateController.initializeWithUserWallet(any()) } just Runs + every { stateController.updateAll(*anyVararg()) } just Runs + every { stateController.update(any>()) } just Runs + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { getUserWalletUseCase(testUserWalletId) } returns Either.Right(testUserWallet) + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { checkAccountInitializedUseCase(testUserWalletId, any()) } returns true.right() + coEvery { isAnyTokenStakedUseCase(testUserWalletId) } returns Either.Right(false) + coEvery { + isAmountSubtractAvailableUseCase(testUserWalletId, any()) + } returns Either.Right(false) + every { getActionsUseCase(testUserWalletId, any()) } returns emptyFlow() + every { getBalanceHidingSettingsUseCase() } returns emptyFlow() + mockBalanceUpdater = mockk { + coEvery { partialUpdate() } just Runs + } + every { + stakingBalanceUpdater.create(any(), any(), any()) + } returns mockBalanceUpdater + } + + @Test + fun `GIVEN currency status emitted WHEN model created THEN analytics sent and fee status fetched`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.value } returns mockk { + every { stakingBalance } returns mockk { + every { balance } returns YieldBalanceItem( + items = listOf( + mockk { every { validatorAddress } returns "address1" }, + mockk { every { validatorAddress } returns "address2" }, + ), + integrationId = "test" + ) + } + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify { + paramsInterceptorHolder.addParamsInterceptor( + match { it.id() == "StakingParamsInterceptorId" } + ) + } + verify { + analyticsEventHandler.send( + StakingAnalyticsEvent.StakingInfoScreenOpened( + validatorsCount = 2 + ), + ) + } + verify { + stateController.initializeWithUserWallet(testUserWallet) + } + + model.onDestroy() + } + + @Test + fun `GIVEN currency status emitted twice WHEN model created THEN analytics sent only once`() = runTest { + val statusFlow = MutableSharedFlow() + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns statusFlow + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + statusFlow.emit(testAccountCurrencyStatus) + advanceUntilIdle() + statusFlow.emit(testAccountCurrencyStatus) + advanceUntilIdle() + + verify(exactly = 1) { + analyticsEventHandler.send( + event = StakingAnalyticsEvent.StakingInfoScreenOpened(validatorsCount = 0) + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN account initialized WHEN checkForTonHeatupCase THEN no error logged`() = runTest { + mockkObject(TangemLogger) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { checkAccountInitializedUseCase(testUserWalletId, any()) } + verify(exactly = 0) { TangemLogger.e(any(), any()) } + + model.onDestroy() + unmockkObject(TangemLogger) + } + + @Test + fun `GIVEN checkAccountInitialized fails WHEN checkForTonHeatupCase THEN error logged`() = runTest { + val testError = RuntimeException("network error") + coEvery { + checkAccountInitializedUseCase(testUserWalletId, any()) + } returns Either.Left(testError) + mockkObject(TangemLogger) + every { TangemLogger.e(any(), any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify { TangemLogger.e("Error", testError) } + + model.onDestroy() + unmockkObject(TangemLogger) + } + + @Test + fun `GIVEN approval needed WHEN setupApprovalNeeded THEN getAllowanceUseCase called`() = runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + mockkObject(StakingIntegrationID.Companion) + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Right(BigDecimal.TEN) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } + + model.onDestroy() + unmockkObject(StakingIntegrationID.Companion) + } + + @Test + fun `GIVEN approval needed AND getAllowance fails WHEN setupApprovalNeeded THEN no crash`() = runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + mockkObject(StakingIntegrationID.Companion) + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Left(RuntimeException("allowance error")) // error + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } + + model.onDestroy() + unmockkObject(StakingIntegrationID.Companion) + } + + @Test + fun `GIVEN any token staked WHEN setupIsAnyTokenStaked THEN use case called with correct wallet id`() = runTest { + coEvery { isAnyTokenStakedUseCase(testUserWalletId) } returns Either.Right(true) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { isAnyTokenStakedUseCase(testUserWalletId) } + + model.onDestroy() + } + + @Test + fun `GIVEN subtract available WHEN checkIfSubtractAvailable THEN use case called with correct params`() = runTest { + coEvery { + isAmountSubtractAvailableUseCase(testUserWalletId, any()) + } returns Either.Right(true) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { isAmountSubtractAvailableUseCase(testUserWalletId, any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN actions emitted WHEN subscribeOnActionsUpdates AND isInitState THEN updateInitialData`() = runTest { + val testActions = listOf(mockk(relaxed = true)) + every { + getActionsUseCase(testUserWalletId, any()) + } returns flowOf(Either.Right(testActions)) + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify(atLeast = 1) { + stateController.updateAll( + match { it is SetInitialDataStateTransformer }, + any(), + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN init step WHEN subscribeOnStepChanges THEN updateInitialData and partialUpdate called`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + verify(atLeast = 1) { + stateController.updateAll( + match { it is SetInitialDataStateTransformer }, + any(), + ) + } + coVerify { mockBalanceUpdater.partialUpdate() } + + model.onDestroy() + } + + @Test + fun `GIVEN assent step AND isWarning WHEN subscribeOnStepChanges THEN getFee AND amount rounded to integer`() = + runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any() + ) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk { + every { innerState } returns InnerConfirmationStakingState.ASSENT + } + every { amountState } returns mockk { + every { amountTextField } returns mockk { + every { isWarning } returns true + } + } + } + uiStateFlow.value = assentUiState + advanceUntilIdle() + + verify { + stateController.update( + match> { it is SetConfirmationStateLoadingTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN balance hidden WHEN subscribeOnBalanceHiding THEN HideBalanceStateTransformer applied`() = runTest { + val balanceHidingSettings = BalanceHidingSettings( + isHidingEnabledInSettings = true, + isBalanceHidden = true, + isBalanceHidingNotificationEnabled = false, + ) + every { getBalanceHidingSettingsUseCase() } returns flowOf(balanceHidingSettings) + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify { + stateController.update( + match> { it is HideBalanceStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onDestroy THEN params interceptor removed`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onDestroy() + + verify { + paramsInterceptorHolder.removeParamsInterceptor("StakingParamsInterceptorId") + } + } + + @Test + fun `WHEN onBackClick THEN router pop and stateController clear called`() = runTest { + every { stateController.value } returns initialUiState + every { appRouter.pop(any()) } just Runs + every { stateController.clear() } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onBackClick() + + verify { appRouter.pop(any()) } + verify { stateController.clear() } + + model.onDestroy() + } + + @Test + fun `GIVEN targets AND no yield balance WHEN onNextClick with balance THEN validators unavailable alert sent`() = + runTest { + every { messageSender.send(any()) } just Runs + every { testYield.allValidatorsFull } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNextClick(balanceState = null) + advanceUntilIdle() + + verify { + messageSender.send( + match { it is DialogMessage } // dialog from StakingModel.stakingEventFactory + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN partial amount disabled WHEN onNextClick with null balance THEN updateAll called with transformers`() = + runTest { + every { stateController.value } returns initialUiState + every { testYield.args.enter.isPartialAmountDisabled } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNextClick(balanceState = null) + advanceUntilIdle() + + verify { + stateController.updateAll( + match { it is SetConfirmationStateInitTransformer }, + match { it is ValidatorSelectChangeTransformer }, + match { it is SetAmountDataTransformer }, + match { it is AmountMaxValueStateTransformer }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN non-initial step WHEN onNextClick THEN only stakingStateRouter onNextClick called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { stateController.value } returns initialUiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + val amountUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Amount + } + uiStateFlow.value = amountUiState + every { stateController.value } returns amountUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + + model.onNextClick(balanceState = null) + advanceUntilIdle() + + verify { + stateController.update(match<(StakingUiState) -> StakingUiState> { true }) + } + verify(exactly = 0) { + stateController.updateAll(*anyVararg()) + } + + model.onDestroy() + } + + @Test + fun `WHEN getFee THEN loading state set and feeLoader called`() = runTest { + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.getFee() + advanceUntilIdle() + + verify { + stateController.update( + transformer = match> { + it is SetConfirmationStateLoadingTransformer + } + ) + } + coVerify { + mockFeeLoader.getFee(any(), any(), any(), any()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN StakeKit integration AND assent state WHEN onActionClick THEN sendTransaction called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + val mockTransactionSender = mockk { + coEvery { send(any()) } just Runs + } + every { + stakingOperationsFactory.createTransactionSender( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any(), + isAmountSubtractAvailable = any() + ) + } returns mockTransactionSender + + val model = createModel(testScope = this) + advanceUntilIdle() + + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk { + every { innerState } returns InnerConfirmationStakingState.ASSENT + } + } + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + model.onActionClick() + advanceUntilIdle() + + verify { + stateController.update( + transformer = match> { + it is SetConfirmationStateInProgressTransformer + } + ) + } + coVerify { mockTransactionSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN P2PEthPool AND fee not increased WHEN onActionClick THEN sendTransaction called directly`() = runTest { + val p2pParams = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = StakingIntegrationID.P2PEthPool, + ) + coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + coEvery { + getBalanceNotEnoughForFeeWarningUseCase( + fee = any(), + userWalletId = any(), + tokenStatus = any(), + feeStatus = any() + ) + } returns Either.Right(null) + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any() + ) + } returns mockk(relaxed = true) + val newFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.ONE + } + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } coAnswers { + firstArg<(Fee, Boolean) -> Unit>().invoke(newFee, false) + } + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + val mockTransactionSender = mockk { + coEvery { send(any()) } just Runs + } + every { + stakingOperationsFactory.createTransactionSender( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any(), + isAmountSubtractAvailable = any() + ) + } returns mockTransactionSender + val currentFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.TEN + } + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.ASSENT + every { feeState } returns mockk(relaxed = true) { + every { fee } returns currentFee + } + } + } + + val model = createModel( + paramsContainer = MutableParamsContainer(p2pParams), + testScope = this, + ) + advanceUntilIdle() + + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + model.onActionClick() + advanceUntilIdle() + + coVerify { mockTransactionSender.send(any()) } + verify(exactly = 0) { messageSender.send(match { it is DialogMessage }) } + + model.onDestroy() + } + + @Test + fun `GIVEN P2PEthPool AND fee increased WHEN onActionClick THEN fee updated alert shown`() = runTest { + val p2pParams = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = StakingIntegrationID.P2PEthPool, + ) + coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + coEvery { + getBalanceNotEnoughForFeeWarningUseCase( + fee = any(), + userWalletId = any(), + tokenStatus = any(), + feeStatus = any() + ) + } returns Either.Right(null) + coEvery { + getCurrencyCheckUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns mockk(relaxed = true) + every { messageSender.send(any()) } just Runs + val newFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.TEN + } + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } coAnswers { + firstArg<(Fee, Boolean) -> Unit>().invoke(newFee, false) + } + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + val mockTransactionSender = mockk { + coEvery { send(any()) } just Runs + } + every { + stakingOperationsFactory.createTransactionSender( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any(), + isAmountSubtractAvailable = any(), + ) + } returns mockTransactionSender + val currentFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.ONE + } + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.ASSENT + every { feeState } returns mockk(relaxed = true) { + every { fee } returns currentFee + } + } + } + + val model = createModel( + paramsContainer = MutableParamsContainer(p2pParams), + testScope = this, + ) + advanceUntilIdle() + + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + model.onActionClick() + advanceUntilIdle() + + verify { + stateController.update( + match> { + it is SetConfirmationStateResetAssentTransformer + }, + ) + } + verify { messageSender.send(any()) } + coVerify(exactly = 0) { mockTransactionSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN assent state AND no approval in progress WHEN onPrevClick THEN prev navigated and assent reset`() = + runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any() + ) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.ASSENT + every { notifications } returns persistentListOf() + } + } + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + every { stateController.update(any>()) } just Runs + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { stateController.value } returns assentUiState + + model.onPrevClick() + + verify { + stateController.update( + match> { + it is SetConfirmationStateResetAssentTransformer + }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN in progress state WHEN onPrevClick THEN nothing happens`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val inProgressUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.IN_PROGRESS + } + } + uiStateFlow.value = inProgressUiState + every { stateController.value } returns inProgressUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + + model.onPrevClick() + + verify(exactly = 0) { stateController.update(any>()) } + verify(exactly = 0) { stateController.update(any<(StakingUiState) -> StakingUiState>()) } + verify(exactly = 0) { stateController.updateAll(*anyVararg()) } + + model.onDestroy() + } + + @Test + fun `GIVEN completed state WHEN onPrevClick THEN onNextClick called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val completedUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.COMPLETED + } + } + uiStateFlow.value = completedUiState + every { stateController.value } returns completedUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + every { stateController.value } returns completedUiState + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { stateController.clear() } just Runs + + model.onPrevClick() + advanceUntilIdle() + + verify { + stateController.update(any<(StakingUiState) -> StakingUiState>()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN non-confirmation step WHEN onPrevClick THEN stakingStateRouter onPrevClick called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { stateController.value } returns initialUiState + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { appRouter.pop(any()) } just Runs + every { stateController.clear() } just Runs + + val amountUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Amount + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + uiStateFlow.value = amountUiState + every { stateController.value } returns amountUiState + every { stateController.uiState } returns MutableStateFlow(amountUiState) + + model.onPrevClick() + + verify { stateController.update(any<(StakingUiState) -> StakingUiState>()) } + + model.onDestroy() + } + + @Test + fun `WHEN onRefreshSwipe true THEN loading set and balanceUpdater partialUpdate called`() = runTest { + val testAppScope = object : AppCoroutineScope, + CoroutineScope by this {} + + val model = createModel( + testScope = this, + coroutineScope = testAppScope, + ) + advanceUntilIdle() + + model.onRefreshSwipe(isRefreshing = true) + advanceUntilIdle() + + verify { + stateController.update( + match> { + it is SetInitialLoadingStateTransformer + } + ) + } + coVerify { mockBalanceUpdater.partialUpdate() } + + model.onDestroy() + } + + @Test + fun `WHEN onInitialInfoBannerClick THEN analytics sent and url opened`() = runTest { + every { innerRouter.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onInitialInfoBannerClick() + + verify { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.WhatIsStaking }) + } + verify { + innerRouter.openUrl("https://tangem.com/en/blog/post/how-to-stake-cryptocurrency/") + } + + model.onDestroy() + } + + @Test + fun `WHEN onInfoClick THEN ShowInfoBottomSheetStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) + + verify { + stateController.update( + match> { + it is ShowInfoBottomSheetStateTransformer + } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN empty preferredTargets WHEN onAmountEnterClick THEN noAvailableValidators alert sent`() = runTest { + every { testYield.preferredValidators } returns emptyList() + every { messageSender.send(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountEnterClick() + + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN non-empty preferredTargets WHEN onAmountEnterClick THEN validator reset and onNextClick called`() = + runTest { + every { stateController.value } returns initialUiState + every { testYield.preferredValidators } returns listOf(mockk(relaxed = true)) + every { initialUiState.actionType } returns StakingActionCommonType.Enter(skipEnterAmount = false) + every { testYield.args.enter.isPartialAmountDisabled } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountEnterClick() + advanceUntilIdle() + + verify { + stateController.updateAll( + match { it is ValidatorSelectChangeTransformer }, + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onAmountPasteTriggerDismiss THEN AmountPasteDismissStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountPasteTriggerDismiss() + + verify { + stateController.update( + transformer = match> { it is AmountPasteDismissStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onMaxValueClick THEN analytics sent and AmountMaxValueStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onMaxValueClick() + + verify { + analyticsEventHandler.send( + match { + it is StakingAnalyticsEvent.ButtonMax + } + ) + } + verify { + stateController.update( + match> { it is AmountMaxValueStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onCurrencyChangeClick THEN analytics sent and AmountCurrencyChangeStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onCurrencyChangeClick(isFiat = true) + + verify { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.AmountSelectCurrency }) + } + verify { + stateController.update( + transformer = match> { it is AmountCurrencyChangeStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN openValidators THEN analytics sent and step changed to Validators`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openValidators() + + verify { + analyticsEventHandler.send( + match { it is StakingAnalyticsEvent.ButtonValidator }, + ) + } + verify { + stateController.update(any<(StakingUiState) -> StakingUiState>()) + } + + model.onDestroy() + } + + @Test + fun `WHEN onTargetSelect THEN analytics sent and ValidatorSelectChangeTransformer applied`() = runTest { + val target: StakingTarget = mockk(relaxed = true) { + every { name } returns "TestValidator" + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onTargetSelect(target) + + verify { + analyticsEventHandler.send(event = StakingAnalyticsEvent.ValidatorChosen("TestValidator")) + } + verify { + stateController.update( + transformer = match> { it is ValidatorSelectChangeTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN RewardsRequirementsError AND minimumAmount WHEN openRewardsValidators THEN alert shown call`() = + runTest { + every { messageSender.send(any()) } just Runs + + val constraints = PendingActionConstraints( + type = StakingActionType.CLAIM_REWARDS, + amountArg = PendingAction.PendingActionArgs.Amount( + required = true, + minimum = BigDecimal.TEN, + maximum = null, + ), + ) + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "1.0", + rewardsFiat = "$1.00", + rewardBlockType = RewardBlockType.RewardsRequirementsError, + rewardConstraints = constraints, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + verify { messageSender.send(any()) } + verify(exactly = 0) { getActionRequirementAmountUseCase.invoke(any(), any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN RewardsRequirementsError WHEN openRewardsValidators THEN getActionRequirementAmountUseCase called`() = + runTest { + every { messageSender.send(any()) } just Runs + every { + getActionRequirementAmountUseCase.invoke(any(), any()) + } returns BigDecimal.ONE + + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "1.0", + rewardsFiat = "$1.00", + rewardBlockType = RewardBlockType.RewardsRequirementsError, + rewardConstraints = null, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + verify { + getActionRequirementAmountUseCase.invoke( + integrationId = "test-integration", + actionType = StakingActionType.CLAIM_REWARDS + ) + } + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN no RewardsRequirementsError AND single reward WHEN openRewardsValidators THEN onActiveStake called`() = + runTest { + every { stateController.value } returns initialUiState + every { messageSender.send(any()) } just Runs + + val singleReward: BalanceState = mockk(relaxed = true) { + every { pendingActions } returns persistentListOf() + } + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "1.0", + rewardsFiat = "$1.00", + rewardBlockType = RewardBlockType.Rewards, + rewardConstraints = null, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val rewardsValidatorsState = mockk(relaxed = true) { + every { rewards } returns persistentListOf(singleReward) + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + every { this@mockk.rewardsValidatorsState } returns rewardsValidatorsState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + // onActiveStake path — ButtonValidator analytics should NOT be sent + verify(exactly = 0) { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonValidator }) + } + + model.onDestroy() + } + + @Test + fun `GIVEN no RewardsRequirementsError AND rewards WHEN openRewardsValidators THEN showRewardsValidators called`() = + runTest { + every { stateController.value } returns initialUiState + + val reward1: BalanceState = mockk(relaxed = true) + val reward2: BalanceState = mockk(relaxed = true) + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "2.0", + rewardsFiat = "$2.00", + rewardBlockType = RewardBlockType.Rewards, + rewardConstraints = null, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val rewardsValidatorsState = mockk(relaxed = true) { + every { rewards } returns persistentListOf(reward1, reward2) + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + every { this@mockk.rewardsValidatorsState } returns rewardsValidatorsState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + verify { + analyticsEventHandler.send( + event = StakingAnalyticsEvent.ButtonValidator(source = StakeScreenSource.Info) + ) + } + verify { + stateController.update(any<(StakingUiState) -> StakingUiState>()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN single pending action WHEN onActiveStake THEN prepareForConfirmation and onNextClick called`() = + runTest { + every { stateController.value } returns initialUiState + + val singleAction = PendingAction( + type = StakingActionType.CLAIM_REWARDS, + passthrough = "test", + args = null, + ) + val activeStake: BalanceState = mockk(relaxed = true) { + every { type } returns BalanceType.STAKED + every { pendingActions } returns persistentListOf(singleAction) + every { target } returns null + every { cryptoValue } returns "100" + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActiveStake(activeStake) + advanceUntilIdle() + + // prepareForConfirmation calls updateAll with 4 transformers + verify { + stateController.updateAll( + match { it is SetConfirmationStateInitTransformer }, + match { it is ValidatorSelectChangeTransformer }, + match { it is SetAmountDataTransformer }, + any(), + ) + } + // onNextClick updates step + verify { stateController.update(any<(StakingUiState) -> StakingUiState>()) } + + model.onDestroy() + } + + @Test + fun `GIVEN multiple pending actions WHEN onActiveStake THEN ShowActionSelectorBottomSheetTransformer applied`() = + runTest { + every { stateController.value } returns initialUiState + + val action1 = PendingAction( + type = StakingActionType.CLAIM_REWARDS, + passthrough = "test1", + args = null, + ) + val action2 = PendingAction( + type = StakingActionType.WITHDRAW, + passthrough = "test2", + args = null, + ) + val activeStake: BalanceState = mockk(relaxed = true) { + every { type } returns BalanceType.STAKED + every { pendingActions } returns persistentListOf(action1, action2) + every { target } returns null + every { cryptoValue } returns "100" + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActiveStake(activeStake) + + verify { + stateController.update( + match> { it is ShowActionSelectorBottomSheetTransformer }, + ) + } + // prepareForConfirmation should NOT have been called + verify(exactly = 0) { + stateController.updateAll( + match { it is SetConfirmationStateInitTransformer }, + any(), any(), any(), + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onActiveStakeAnalytic THEN ButtonValidator analytics sent`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActiveStakeAnalytic() + + verify { + analyticsEventHandler.send( + StakingAnalyticsEvent.ButtonValidator( + source = StakeScreenSource.Info, + ) + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN gasless approval enabled WHEN showApprovalBottomSheet THEN approvalSlotNavigation activated`() = + runTest { + every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.showApprovalBottomSheet() + + verify(exactly = 0) { + stateController.update( + transformer = match> { it is ShowApprovalBottomSheetTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN gasless disabled WHEN showApprovalBottomSheet THEN ShowApprovalBottomSheetTransformer applied`() = + runTest { + every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns false + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.showApprovalBottomSheet() + + verify { + stateController.update( + transformer = match> { it is ShowApprovalBottomSheetTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onApproveTypeChange THEN SetApprovalBottomSheetTypeChangeTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onApproveTypeChange(ApproveType.LIMITED) + + verify { + stateController.update( + transformer = match> { it is SetApprovalBottomSheetTypeChangeTransformer }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN approval needed WHEN onApprovalClick THEN in progress set and createApprovalTransaction called`() = + runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + val expectedNetwork = mockk { + every { name } returns "KEK" + } + val testToken: CryptoCurrency.Token = mockk(relaxed = true) { + every { network } returns expectedNetwork + } + val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) { + every { currency } returns testToken + } + val testAccountCurrencyStatus = mockk { + every { component1() } returns mockk(relaxed = true) + every { component2() } returns testCryptoCurrencyStatus + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + // Setup stakingApproval = Needed + mockkObject(StakingIntegrationID.Companion) + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Right(BigDecimal.TEN) + + every { + stakingOperationsFactory.createFeeLoader( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any() + ) + } returns mockk { + coEvery { + getFee( + onStakingFee = any(), + onStakingFeeError = any(), + onApprovalFee = any(), + onFeeError = any() + ) + } just Runs + } + val expectedApprovalTx = Either.Right(mockk(relaxed = true)) + coEvery { + createApprovalTransactionUseCase.invoke( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = any(), + fee = any(), + contractAddress = any(), + spenderAddress = any(), + ) + } returns expectedApprovalTx + coEvery { + sendTransactionUseCase(any(), any(), any()) + } returns Either.Right("txHash") + every { vibratorHapticManager.performOneTime(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + // Now override stateController.value with confirmation state after cryptoCurrencyStatus is initialized + val testFee: Fee.Common = mockk(relaxed = true) + val confirmationState = mockk(relaxed = true) { + every { feeState } returns mockk(relaxed = true) { + every { fee } returns testFee + } + } + val uiState = mockk(relaxed = true) { + every { this@mockk.confirmationState } returns confirmationState + every { bottomSheetConfig } returns null + } + every { stateController.value } returns uiState + + model.onApprovalClick() + advanceUntilIdle() + + verify { + stateController.update( + transformer = match> { + it is SetApprovalBottomSheetInProgressTransformer + }, + ) + } + coVerify { + sendTransactionUseCase( + txData = expectedApprovalTx.value, + userWallet = testUserWallet, + network = expectedNetwork, + ) + } + + model.onDestroy() + unmockkObject(StakingIntegrationID.Companion) + } + + @Test + fun `WHEN onAmountReduceByClick THEN AmountReduceByStateTransformer and DismissNotification applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountReduceByClick( + reduceAmountBy = BigDecimal.ONE, + reduceAmountByDiff = BigDecimal.TEN, + notification = NotificationUM::class.java, + ) + + verify { + stateController.update( + transformer = match> { it is AmountReduceByStateTransformer } + ) + } + verify { + stateController.update( + transformer = match> { it is DismissStakingNotificationsStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onAmountReduceToClick THEN AmountReduceToStateTransformer and DismissNotification applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountReduceToClick( + reduceAmountTo = BigDecimal.ONE, + notification = NotificationUM::class.java, + ) + + verify { + stateController.update( + transformer = match> { it is AmountReduceToStateTransformer } + ) + } + verify { + stateController.update( + transformer = match> { + it is DismissStakingNotificationsStateTransformer + } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onNotificationCancel THEN DismissStakingNotificationsStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNotificationCancel(NotificationUM::class.java) + + verify { + stateController.update( + transformer = match> { it is DismissStakingNotificationsStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN txUrl not null WHEN onExploreClick THEN analytics sent and url opened`() = runTest { + val txUrl = "https://explorer.solana.com/tx/abc123" + val transactionDoneState = TransactionDoneState.Content( + timestamp = 1000L, + txUrl = txUrl, + ) + val confirmationState = mockk(relaxed = true) { + every { this@mockk.transactionDoneState } returns transactionDoneState + } + val uiState = mockk(relaxed = true) { + every { this@mockk.confirmationState } returns confirmationState + } + every { stateController.uiState } returns MutableStateFlow(uiState) + every { innerRouter.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onExploreClick() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonExplore }) } + verify { innerRouter.openUrl(txUrl) } + + model.onDestroy() + } + + @Test + fun `GIVEN txUrl not null WHEN onShareClick THEN analytics sent and shareManager called`() = runTest { + val txUrl = "https://explorer.solana.com/tx/abc123" + val transactionDoneState = TransactionDoneState.Content( + timestamp = 1000L, + txUrl = txUrl, + ) + val confirmationState = mockk(relaxed = true) { + every { this@mockk.transactionDoneState } returns transactionDoneState + } + val uiState = mockk(relaxed = true) { + every { this@mockk.confirmationState } returns confirmationState + } + every { stateController.uiState } returns MutableStateFlow(uiState) + every { vibratorHapticManager.performOneTime(any()) } just Runs + every { shareManager.shareText(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onShareClick() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonShare }) } + verify { vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) } + verify { shareManager.shareText(txUrl) } + + model.onDestroy() + } + + @Test + fun `WHEN onFailedTxEmailClick THEN analytics sent and sendFeedbackEmail called`() = runTest { + coEvery { getWalletMetaInfoUseCase(userWalletId = any()) } returns Either.Right(mockk(relaxed = true)) + every { saveBlockchainErrorUseCase(error = any()) } just Runs + coEvery { sendFeedbackEmailUseCase(type = any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onFailedTxEmailClick("test error") + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { it is Basic.ButtonSupport }) } + coVerify { sendFeedbackEmailUseCase(match { it is FeedbackEmailType.StakingProblem }) } + + model.onDestroy() + } + + @Test + fun `WHEN openTokenDetails THEN innerRouter openTokenDetails called`() = runTest { + every { innerRouter.openTokenDetails(any(), any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + val currency: CryptoCurrency = mockk(relaxed = true) + model.openTokenDetails(currency) + + verify { innerRouter.openTokenDetails(testUserWalletId, currency) } + + model.onDestroy() + } + + @Test + fun `WHEN showPrimaryClickAlert THEN messageSender sends alert`() = runTest { + every { messageSender.send(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.showPrimaryClickAlert() + + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `WHEN onOpenLearnMoreAboutApproveClick THEN urlOpener opens approve url`() = runTest { + every { urlOpener.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onOpenLearnMoreAboutApproveClick() + + verify { urlOpener.openUrl("https://tangem.com/en/blog/post/give-revoke-permission/") } + + model.onDestroy() + } + + @Test + fun `GIVEN getFee returns Left WHEN onActivateTonAccountNotificationClick THEN fee error transformer applied`() = + runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency } returns mockk { + every { id } returns CryptoCurrency.ID.fromValue("coin⟨ethereum→-1843072795⟩ethereum") + every { symbol } returns "KEK" + every { network } returns mockk() + every { decimals } returns 2 + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Left(mockk(relaxed = true)) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddressScreen("KEK")) + } + verify { + stateController.update( + transformer = match> { it is ShowTonInitializeBottomSheetTransformer } + ) + } + verify { + stateController.update( + transformer = match> { + it is SetFeeErrorToTonInitializeBottomSheetTransformer + }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN getFee returns Right WHEN onActivateTonAccountNotificationClick THEN fee transformer applied`() = + runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency } returns mockk { + every { id } returns CryptoCurrency.ID.fromValue("coin⟨ethereum→-1843072795⟩ethereum") + every { symbol } returns "SHMEK" + every { network } returns mockk() + every { decimals } returns 2 + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Right(mockk(relaxed = true)) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddressScreen("SHMEK")) + } + verify { + stateController.update( + transformer = match> { it is ShowTonInitializeBottomSheetTransformer } + ) + } + verify { + stateController.update( + match> { + it is SetFeeToTonInitializeBottomSheetTransformer + }, + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onActivateTonAccountNotificationShow THEN UninitializedAddress analytics sent with token`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "TON" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActivateTonAccountNotificationShow() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddress(token = "TON")) + } + + model.onDestroy() + } + + @Test + fun `WHEN onNotEnoughFeeNotificationShow THEN NotEnoughFee analytics sent with token`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "SOL" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNotEnoughFeeNotificationShow() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.NotEnoughFee(token = "SOL")) + } + + model.onDestroy() + } + + @Test + fun `GIVEN sendTransaction returns Left WHEN onActivateTonAccountClick THEN error dialog sent`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "TON" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + sendTransactionUseCase(any(), any(), any()) + } returns Either.Left(mockk(relaxed = true)) + every { messageSender.send(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + // First populate tonAccountInitializeTransaction + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + model.onActivateTonAccountClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.ButtonActivate(token = "TON")) + } + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN sendTransaction returns Right WHEN onActivateTonAccountClick THEN complete transformer applied`() = + runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "TON" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + val mockBalanceUpdater: StakingBalanceUpdater = mockk { + coEvery { partialUpdate() } just Runs + coEvery { partialUpdateWithDelay() } just Runs + } + every { + stakingBalanceUpdater.create(any(), any(), any()) + } returns mockBalanceUpdater + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + sendTransactionUseCase(any(), any(), any()) + } returns Either.Right("txHash") + + val model = createModel(testScope = this) + advanceUntilIdle() + + // First populate tonAccountInitializeTransaction + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + model.onActivateTonAccountClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.ButtonActivate(token = "TON")) + } + verify { + stateController.update( + match> { it is CompleteInitializeBottomSheetTransformer }, + ) + } + coVerify { mockBalanceUpdater.partialUpdateWithDelay() } + + model.onDestroy() + } + + @Test + fun `WHEN onAmountReduceByFeeClick THEN AmountReduceByStateTransformer and DismissNotification applied`() = + runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountReduceByFeeClick( + reduceAmount = BigDecimal.ONE, + notification = NotificationUM::class.java, + ) + + verify { + stateController.update( + transformer = match> { it is AmountReduceByStateTransformer } + ) + } + verify { + stateController.update( + transformer = match> { + it is DismissStakingNotificationsStateTransformer + } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN approval needed AND amountState data WHEN getApprovalParams THEN returns non-null params`() = runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + + mockkObject(StakingIntegrationID.Companion) + try { + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Right(BigDecimal.TEN) + + val amountState = mockk(relaxed = true) { + every { amountTextField.value } returns "100" + } + val uiState = mockk(relaxed = true) { + every { this@mockk.amountState } returns amountState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + val result = model.getApprovalParams() + + assert(result != null) { "Expected non-null GiveApprovalComponent.Params" } + assert(result!!.spenderAddress == spenderAddress) { + "Expected spenderAddress=$spenderAddress, got=${result.spenderAddress}" + } + + model.onDestroy() + } finally { + unmockkObject(StakingIntegrationID.Companion) + } + } + + @Suppress("LongParameterList") + private fun createModel( + testScope: TestScope, + paramsContainer: ParamsContainer = MutableParamsContainer(testParams), + coroutineScope: AppCoroutineScope = this.coroutineScope, + ): StakingModel { + return StakingModel( + paramsContainer = paramsContainer, + stateController = stateController, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getUserWalletUseCase = getUserWalletUseCase, + sendTransactionUseCase = sendTransactionUseCase, + createApprovalTransactionUseCase = createApprovalTransactionUseCase, + getAllowanceUseCase = getAllowanceUseCase, + vibratorHapticManager = vibratorHapticManager, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, + getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, + getCurrencyCheckUseCase = getCurrencyCheckUseCase, + isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, + isAnyTokenStakedUseCase = isAnyTokenStakedUseCase, + invalidatePendingTransactionsUseCase = invalidatePendingTransactionsUseCase, + stakingOperationsFactory = stakingOperationsFactory, + stakingBalanceUpdater = stakingBalanceUpdater, + analyticsEventHandler = analyticsEventHandler, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + getActionsUseCase = getActionsUseCase, + getYieldUseCase = getYieldUseCase, + p2pEthPoolRepository = p2pEthPoolRepository, + checkAccountInitializedUseCase = checkAccountInitializedUseCase, + createTransferTransactionUseCase = createTransferTransactionUseCase, + getFeeUseCase = getFeeUseCase, + getNetworkAddressesUseCase = getNetworkAddressesUseCase, + getActionRequirementAmountUseCase = getActionRequirementAmountUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase, + paramsInterceptorHolder = paramsInterceptorHolder, + shareManager = shareManager, + urlOpener = urlOpener, + coroutineScope = coroutineScope, + innerRouter = innerRouter, + messageSender = messageSender, + giveApprovalFeatureToggles = giveApprovalFeatureToggles, + appRouter = appRouter, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private fun createMockedAccountCurrencyStatus(): Pair { + val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) + val testAccountCurrencyStatus = mockk { + every { component1() } returns mockk(relaxed = true) + every { component2() } returns testCryptoCurrencyStatus + } + return testCryptoCurrencyStatus to testAccountCurrencyStatus + } +} \ No newline at end of file From 24588d10b5ad0a9aad7d7a6cf933e188943181fa Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Apr 2026 17:42:32 +0100 Subject: [PATCH 057/206] Updated on 2026-08-14 --- .../model/StakingModelAmountTest.kt | 170 ++ .../model/StakingModelInitTest.kt | 327 +++ .../model/StakingModelNavigationTest.kt | 492 ++++ .../presentation/model/StakingModelTest.kt | 2247 ----------------- .../model/StakingModelTestBase.kt | 228 ++ .../model/StakingModelTransactionTest.kt | 792 ++++++ .../model/StakingModelValidatorTest.kt | 351 +++ 7 files changed, 2360 insertions(+), 2247 deletions(-) create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelAmountTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelInitTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt delete mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt create mode 100644 features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelAmountTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelAmountTest.kt new file mode 100644 index 0000000000..a5abf73429 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelAmountTest.kt @@ -0,0 +1,170 @@ +package com.tangem.features.staking.impl.presentation.model + +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.transformers.amount.* +import com.tangem.features.staking.impl.presentation.state.transformers.notifications.DismissStakingNotificationsStateTransformer +import com.tangem.utils.transformer.Transformer +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelAmountTest : StakingModelTestBase() { + + @Test + fun `WHEN onAmountPasteTriggerDismiss THEN AmountPasteDismissStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountPasteTriggerDismiss() + + verify { + stateController.update( + transformer = match> { it is AmountPasteDismissStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onMaxValueClick THEN analytics sent and AmountMaxValueStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onMaxValueClick() + + verify { + analyticsEventHandler.send( + match { + it is StakingAnalyticsEvent.ButtonMax + } + ) + } + verify { + stateController.update( + match> { it is AmountMaxValueStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onCurrencyChangeClick THEN analytics sent and AmountCurrencyChangeStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onCurrencyChangeClick(isFiat = true) + + verify { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.AmountSelectCurrency }) + } + verify { + stateController.update( + transformer = match> { it is AmountCurrencyChangeStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onAmountReduceByClick THEN AmountReduceByStateTransformer and DismissNotification applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountReduceByClick( + reduceAmountBy = BigDecimal.ONE, + reduceAmountByDiff = BigDecimal.TEN, + notification = NotificationUM::class.java, + ) + + verify { + stateController.update( + transformer = match> { it is AmountReduceByStateTransformer } + ) + } + verify { + stateController.update( + transformer = match> { it is DismissStakingNotificationsStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onAmountReduceToClick THEN AmountReduceToStateTransformer and DismissNotification applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountReduceToClick( + reduceAmountTo = BigDecimal.ONE, + notification = NotificationUM::class.java, + ) + + verify { + stateController.update( + transformer = match> { it is AmountReduceToStateTransformer } + ) + } + verify { + stateController.update( + transformer = match> { + it is DismissStakingNotificationsStateTransformer + } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onAmountReduceByFeeClick THEN AmountReduceByStateTransformer and DismissNotification applied`() = + runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountReduceByFeeClick( + reduceAmount = BigDecimal.ONE, + notification = NotificationUM::class.java, + ) + + verify { + stateController.update( + transformer = match> { it is AmountReduceByStateTransformer } + ) + } + verify { + stateController.update( + transformer = match> { + it is DismissStakingNotificationsStateTransformer + } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onNotificationCancel THEN DismissStakingNotificationsStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNotificationCancel(NotificationUM::class.java) + + verify { + stateController.update( + transformer = match> { it is DismissStakingNotificationsStateTransformer } + ) + } + + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelInitTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelInitTest.kt new file mode 100644 index 0000000000..d888bc82b8 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelInitTest.kt @@ -0,0 +1,327 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.domain.balancehiding.BalanceHidingSettings +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.models.staking.YieldBalanceItem +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.stakekit.action.StakingAction +import com.tangem.features.staking.impl.presentation.state.InnerConfirmationStakingState +import com.tangem.features.staking.impl.presentation.state.StakingStates +import com.tangem.features.staking.impl.presentation.state.StakingStep +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader +import com.tangem.features.staking.impl.presentation.state.transformers.HideBalanceStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateLoadingTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetInitialDataStateTransformer +import com.tangem.utils.logging.TangemLogger +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelInitTest : StakingModelTestBase() { + + @Test + fun `GIVEN currency status emitted WHEN model created THEN analytics sent and fee status fetched`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.value } returns mockk { + every { stakingBalance } returns mockk { + every { balance } returns YieldBalanceItem( + items = listOf( + mockk { every { validatorAddress } returns "address1" }, + mockk { every { validatorAddress } returns "address2" }, + ), + integrationId = "test" + ) + } + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify { + paramsInterceptorHolder.addParamsInterceptor( + match { it.id() == "StakingParamsInterceptorId" } + ) + } + verify { + analyticsEventHandler.send( + StakingAnalyticsEvent.StakingInfoScreenOpened( + validatorsCount = 2 + ), + ) + } + verify { + stateController.initializeWithUserWallet(testUserWallet) + } + + model.onDestroy() + } + + @Test + fun `GIVEN currency status emitted twice WHEN model created THEN analytics sent only once`() = runTest { + val statusFlow = MutableSharedFlow() + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns statusFlow + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + statusFlow.emit(testAccountCurrencyStatus) + advanceUntilIdle() + statusFlow.emit(testAccountCurrencyStatus) + advanceUntilIdle() + + verify(exactly = 1) { + analyticsEventHandler.send( + event = StakingAnalyticsEvent.StakingInfoScreenOpened(validatorsCount = 0) + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN account initialized WHEN checkForTonHeatupCase THEN no error logged`() = runTest { + mockkObject(TangemLogger) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { checkAccountInitializedUseCase(testUserWalletId, any()) } + verify(exactly = 0) { TangemLogger.e(any(), any()) } + + model.onDestroy() + unmockkObject(TangemLogger) + } + + @Test + fun `GIVEN checkAccountInitialized fails WHEN checkForTonHeatupCase THEN error logged`() = runTest { + val testError = RuntimeException("network error") + coEvery { + checkAccountInitializedUseCase(testUserWalletId, any()) + } returns Either.Left(testError) + mockkObject(TangemLogger) + every { TangemLogger.e(any(), any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify { TangemLogger.e("Error", testError) } + + model.onDestroy() + unmockkObject(TangemLogger) + } + + @Test + fun `GIVEN approval needed WHEN setupApprovalNeeded THEN getAllowanceUseCase called`() = runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + mockkObject(StakingIntegrationID.Companion) + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Right(BigDecimal.TEN) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } + + model.onDestroy() + unmockkObject(StakingIntegrationID.Companion) + } + + @Test + fun `GIVEN approval needed AND getAllowance fails WHEN setupApprovalNeeded THEN no crash`() = runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + mockkObject(StakingIntegrationID.Companion) + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Left(RuntimeException("allowance error")) // error + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } + + model.onDestroy() + unmockkObject(StakingIntegrationID.Companion) + } + + @Test + fun `GIVEN any token staked WHEN setupIsAnyTokenStaked THEN use case called with correct wallet id`() = runTest { + coEvery { isAnyTokenStakedUseCase(testUserWalletId) } returns Either.Right(true) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { isAnyTokenStakedUseCase(testUserWalletId) } + + model.onDestroy() + } + + @Test + fun `GIVEN subtract available WHEN checkIfSubtractAvailable THEN use case called with correct params`() = runTest { + coEvery { + isAmountSubtractAvailableUseCase(testUserWalletId, any()) + } returns Either.Right(true) + + val model = createModel(testScope = this) + advanceUntilIdle() + + coVerify { isAmountSubtractAvailableUseCase(testUserWalletId, any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN actions emitted WHEN subscribeOnActionsUpdates AND isInitState THEN updateInitialData`() = runTest { + val testActions = listOf(mockk(relaxed = true)) + every { + getActionsUseCase(testUserWalletId, any()) + } returns flowOf(Either.Right(testActions)) + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify(atLeast = 1) { + stateController.updateAll( + match { it is SetInitialDataStateTransformer }, + any(), + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN init step WHEN subscribeOnStepChanges THEN updateInitialData and partialUpdate called`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + verify(atLeast = 1) { + stateController.updateAll( + match { it is SetInitialDataStateTransformer }, + any(), + ) + } + coVerify { mockBalanceUpdater.partialUpdate() } + + model.onDestroy() + } + + @Test + fun `GIVEN assent step AND isWarning WHEN subscribeOnStepChanges THEN getFee AND amount rounded to integer`() = + runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any() + ) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk { + every { innerState } returns InnerConfirmationStakingState.ASSENT + } + every { amountState } returns mockk { + every { amountTextField } returns mockk { + every { isWarning } returns true + } + } + } + uiStateFlow.value = assentUiState + advanceUntilIdle() + + verify { + stateController.update( + match> { it is SetConfirmationStateLoadingTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN balance hidden WHEN subscribeOnBalanceHiding THEN HideBalanceStateTransformer applied`() = runTest { + val balanceHidingSettings = BalanceHidingSettings( + isHidingEnabledInSettings = true, + isBalanceHidden = true, + isBalanceHidingNotificationEnabled = false, + ) + every { getBalanceHidingSettingsUseCase() } returns flowOf(balanceHidingSettings) + + val model = createModel(testScope = this) + advanceUntilIdle() + + verify { + stateController.update( + match> { it is HideBalanceStateTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onDestroy THEN params interceptor removed`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onDestroy() + + verify { + paramsInterceptorHolder.removeParamsInterceptor("StakingParamsInterceptorId") + } + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt new file mode 100644 index 0000000000..c933a896b6 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt @@ -0,0 +1,492 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +import com.tangem.core.analytics.models.Basic +import com.tangem.core.ui.haptic.TangemHapticEffect +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.feedback.models.FeedbackEmailType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType +import com.tangem.features.staking.impl.presentation.state.transformers.* +import com.tangem.features.staking.impl.presentation.state.transformers.amount.AmountMaxValueStateTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelNavigationTest : StakingModelTestBase() { + + @Test + fun `WHEN onBackClick THEN router pop and stateController clear called`() = runTest { + every { stateController.value } returns initialUiState + every { appRouter.pop(any()) } just Runs + every { stateController.clear() } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onBackClick() + + verify { appRouter.pop(any()) } + verify { stateController.clear() } + + model.onDestroy() + } + + @Test + fun `GIVEN targets AND no yield balance WHEN onNextClick with balance THEN validators unavailable alert sent`() = + runTest { + every { messageSender.send(any()) } just Runs + every { testYield.allValidatorsFull } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNextClick(balanceState = null) + advanceUntilIdle() + + verify { + messageSender.send( + match { it is DialogMessage } // dialog from StakingModel.stakingEventFactory + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN partial amount disabled WHEN onNextClick with null balance THEN updateAll called with transformers`() = + runTest { + every { stateController.value } returns initialUiState + every { testYield.args.enter.isPartialAmountDisabled } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNextClick(balanceState = null) + advanceUntilIdle() + + verify { + stateController.updateAll( + match { it is SetConfirmationStateInitTransformer }, + match { it is ValidatorSelectChangeTransformer }, + match { it is SetAmountDataTransformer }, + match { it is AmountMaxValueStateTransformer }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN non-initial step WHEN onNextClick THEN only stakingStateRouter onNextClick called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { stateController.value } returns initialUiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + val amountUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Amount + } + uiStateFlow.value = amountUiState + every { stateController.value } returns amountUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + + model.onNextClick(balanceState = null) + advanceUntilIdle() + + verify { + stateController.update(match<(StakingUiState) -> StakingUiState> { true }) + } + verify(exactly = 0) { + stateController.updateAll(*anyVararg()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN assent state AND no approval in progress WHEN onPrevClick THEN prev navigated and assent reset`() = + runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any() + ) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.ASSENT + every { notifications } returns persistentListOf() + } + } + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + every { stateController.update(any>()) } just Runs + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { stateController.value } returns assentUiState + + model.onPrevClick() + + verify { + stateController.update( + match> { + it is SetConfirmationStateResetAssentTransformer + }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN in progress state WHEN onPrevClick THEN nothing happens`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val inProgressUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.IN_PROGRESS + } + } + uiStateFlow.value = inProgressUiState + every { stateController.value } returns inProgressUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + + model.onPrevClick() + + verify(exactly = 0) { stateController.update(any>()) } + verify(exactly = 0) { stateController.update(any<(StakingUiState) -> StakingUiState>()) } + verify(exactly = 0) { stateController.updateAll(*anyVararg()) } + + model.onDestroy() + } + + @Test + fun `GIVEN completed state WHEN onPrevClick THEN onNextClick called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + val completedUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.COMPLETED + } + } + uiStateFlow.value = completedUiState + every { stateController.value } returns completedUiState + advanceUntilIdle() + + clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) + every { stateController.value } returns completedUiState + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { stateController.clear() } just Runs + + model.onPrevClick() + advanceUntilIdle() + + verify { + stateController.update(any<(StakingUiState) -> StakingUiState>()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN non-confirmation step WHEN onPrevClick THEN stakingStateRouter onPrevClick called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + every { stateController.value } returns initialUiState + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { appRouter.pop(any()) } just Runs + every { stateController.clear() } just Runs + + val amountUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Amount + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + uiStateFlow.value = amountUiState + every { stateController.value } returns amountUiState + every { stateController.uiState } returns MutableStateFlow(amountUiState) + + model.onPrevClick() + + verify { stateController.update(any<(StakingUiState) -> StakingUiState>()) } + + model.onDestroy() + } + + @Test + fun `WHEN onRefreshSwipe true THEN loading set and balanceUpdater partialUpdate called`() = runTest { + val testAppScope = object : AppCoroutineScope, + CoroutineScope by this {} + + val model = createModel( + testScope = this, + coroutineScope = testAppScope, + ) + advanceUntilIdle() + + model.onRefreshSwipe(isRefreshing = true) + advanceUntilIdle() + + verify { + stateController.update( + match> { + it is SetInitialLoadingStateTransformer + } + ) + } + coVerify { mockBalanceUpdater.partialUpdate() } + + model.onDestroy() + } + + @Test + fun `WHEN onInitialInfoBannerClick THEN analytics sent and url opened`() = runTest { + every { innerRouter.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onInitialInfoBannerClick() + + verify { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.WhatIsStaking }) + } + verify { + innerRouter.openUrl("https://tangem.com/en/blog/post/how-to-stake-cryptocurrency/") + } + + model.onDestroy() + } + + @Test + fun `WHEN onInfoClick THEN ShowInfoBottomSheetStateTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) + + verify { + stateController.update( + match> { + it is ShowInfoBottomSheetStateTransformer + } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN empty preferredTargets WHEN onAmountEnterClick THEN noAvailableValidators alert sent`() = runTest { + every { testYield.preferredValidators } returns emptyList() + every { messageSender.send(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountEnterClick() + + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN non-empty preferredTargets WHEN onAmountEnterClick THEN validator reset and onNextClick called`() = + runTest { + every { stateController.value } returns initialUiState + every { testYield.preferredValidators } returns listOf(mockk(relaxed = true)) + every { initialUiState.actionType } returns StakingActionCommonType.Enter(skipEnterAmount = false) + every { testYield.args.enter.isPartialAmountDisabled } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onAmountEnterClick() + advanceUntilIdle() + + verify { + stateController.updateAll( + match { it is ValidatorSelectChangeTransformer }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN txUrl not null WHEN onExploreClick THEN analytics sent and url opened`() = runTest { + val txUrl = "https://explorer.solana.com/tx/abc123" + val transactionDoneState = TransactionDoneState.Content( + timestamp = 1000L, + txUrl = txUrl, + ) + val confirmationState = mockk(relaxed = true) { + every { this@mockk.transactionDoneState } returns transactionDoneState + } + val uiState = mockk(relaxed = true) { + every { this@mockk.confirmationState } returns confirmationState + } + every { stateController.uiState } returns MutableStateFlow(uiState) + every { innerRouter.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onExploreClick() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonExplore }) } + verify { innerRouter.openUrl(txUrl) } + + model.onDestroy() + } + + @Test + fun `GIVEN txUrl not null WHEN onShareClick THEN analytics sent and shareManager called`() = runTest { + val txUrl = "https://explorer.solana.com/tx/abc123" + val transactionDoneState = TransactionDoneState.Content( + timestamp = 1000L, + txUrl = txUrl, + ) + val confirmationState = mockk(relaxed = true) { + every { this@mockk.transactionDoneState } returns transactionDoneState + } + val uiState = mockk(relaxed = true) { + every { this@mockk.confirmationState } returns confirmationState + } + every { stateController.uiState } returns MutableStateFlow(uiState) + every { vibratorHapticManager.performOneTime(any()) } just Runs + every { shareManager.shareText(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onShareClick() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonShare }) } + verify { vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) } + verify { shareManager.shareText(txUrl) } + + model.onDestroy() + } + + @Test + fun `WHEN onFailedTxEmailClick THEN analytics sent and sendFeedbackEmail called`() = runTest { + coEvery { getWalletMetaInfoUseCase(userWalletId = any()) } returns Either.Right(mockk(relaxed = true)) + every { saveBlockchainErrorUseCase(error = any()) } just Runs + coEvery { sendFeedbackEmailUseCase(type = any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onFailedTxEmailClick("test error") + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { it is Basic.ButtonSupport }) } + coVerify { sendFeedbackEmailUseCase(match { it is FeedbackEmailType.StakingProblem }) } + + model.onDestroy() + } + + @Test + fun `WHEN openTokenDetails THEN innerRouter openTokenDetails called`() = runTest { + every { innerRouter.openTokenDetails(any(), any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + val currency: CryptoCurrency = mockk(relaxed = true) + model.openTokenDetails(currency) + + verify { innerRouter.openTokenDetails(testUserWalletId, currency) } + + model.onDestroy() + } + + @Test + fun `WHEN showPrimaryClickAlert THEN messageSender sends alert`() = runTest { + every { messageSender.send(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.showPrimaryClickAlert() + + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `WHEN onOpenLearnMoreAboutApproveClick THEN urlOpener opens approve url`() = runTest { + every { urlOpener.openUrl(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onOpenLearnMoreAboutApproveClick() + + verify { urlOpener.openUrl("https://tangem.com/en/blog/post/give-revoke-permission/") } + + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTest.kt deleted file mode 100644 index 547b0cb61e..0000000000 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTest.kt +++ /dev/null @@ -1,2247 +0,0 @@ -package com.tangem.features.staking.impl.presentation.model - -import arrow.core.Either -import arrow.core.right -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.transaction.Fee -import com.tangem.common.routing.AppRouter -import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType -import com.tangem.common.ui.notifications.NotificationUM -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.api.ParamsInterceptorHolder -import com.tangem.core.analytics.models.Basic -import com.tangem.core.decompose.model.MutableParamsContainer -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.share.ShareManager -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.haptic.TangemHapticEffect -import com.tangem.core.ui.haptic.VibratorHapticManager -import com.tangem.core.ui.message.DialogMessage -import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus -import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase -import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase -import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.balancehiding.BalanceHidingSettings -import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.feedback.GetWalletMetaInfoUseCase -import com.tangem.domain.feedback.SaveBlockchainErrorUseCase -import com.tangem.domain.feedback.SendFeedbackEmailUseCase -import com.tangem.domain.feedback.models.FeedbackEmailType -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.staking.* -import com.tangem.domain.models.staking.action.StakingActionType -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.staking.* -import com.tangem.domain.staking.analytics.StakeScreenSource -import com.tangem.domain.staking.analytics.StakingAnalyticsEvent -import com.tangem.domain.staking.model.StakingApproval -import com.tangem.domain.staking.model.StakingIntegrationID -import com.tangem.domain.staking.model.StakingTarget -import com.tangem.domain.staking.model.stakekit.Yield -import com.tangem.domain.staking.model.stakekit.action.StakingAction -import com.tangem.domain.staking.model.stakekit.action.StakingActionCommonType -import com.tangem.domain.staking.repositories.P2PEthPoolRepository -import com.tangem.domain.tokens.* -import com.tangem.domain.transaction.usecase.* -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.features.approval.api.GiveApprovalFeatureToggles -import com.tangem.features.staking.api.StakingComponent -import com.tangem.features.staking.impl.navigation.InnerStakingRouter -import com.tangem.features.staking.impl.presentation.state.* -import com.tangem.features.staking.impl.presentation.state.bottomsheet.InfoType -import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater -import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader -import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory -import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender -import com.tangem.features.staking.impl.presentation.state.transformers.* -import com.tangem.features.staking.impl.presentation.state.transformers.amount.* -import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetInProgressTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetTypeChangeTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.approval.ShowApprovalBottomSheetTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.notifications.DismissStakingNotificationsStateTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.ton.CompleteInitializeBottomSheetTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeErrorToTonInitializeBottomSheetTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeToTonInitializeBottomSheetTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.ton.ShowTonInitializeBottomSheetTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer -import com.tangem.utils.coroutines.AppCoroutineScope -import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import com.tangem.utils.logging.TangemLogger -import com.tangem.utils.transformer.Transformer -import io.mockk.* -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow -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.BeforeEach -import org.junit.jupiter.api.Test -import java.math.BigDecimal - -@OptIn(ExperimentalCoroutinesApi::class) -internal class StakingModelTest { - - private val testUserWalletId = UserWalletId("1234567890ABCDEF") - private val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) - private val testIntegrationId = StakingIntegrationID.StakeKit.Coin.Solana - private val testParams = StakingComponent.Params( - userWalletId = testUserWalletId, - cryptoCurrency = testCryptoCurrency, - integrationId = testIntegrationId, - ) - private val testYield: Yield = mockk(relaxed = true) - private val testUserWallet: UserWallet = mockk(relaxed = true) - private val initialUiState: StakingUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.InitialInfo - } - - private lateinit var testCryptoCurrencyStatus: CryptoCurrencyStatus - private lateinit var testAccountCurrencyStatus: AccountCryptoCurrencyStatus - private lateinit var mockBalanceUpdater: StakingBalanceUpdater - - private val stateController: StakingStateController = mockk() - private val getYieldUseCase: GetYieldUseCase = mockk() - private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() - private val getUserWalletUseCase: GetUserWalletUseCase = mockk() - private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk() - private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) - private val appRouter: AppRouter = mockk() - - private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk() - private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk() - private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase = mockk() - private val sendTransactionUseCase: SendTransactionUseCase = mockk() - private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk() - private val getAllowanceUseCase: GetAllowanceUseCase = mockk() - private val vibratorHapticManager: VibratorHapticManager = mockk() - private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk() - private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk() - private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk() - private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() - private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() - private val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase = mockk() - private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase = mockk() - private val stakingOperationsFactory: StakingOperationsFactory = mockk() - private val stakingBalanceUpdater: StakingBalanceUpdater.Factory = mockk() - private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk() - private val getActionsUseCase: GetActionsUseCase = mockk() - private val p2pEthPoolRepository: P2PEthPoolRepository = mockk() - private val checkAccountInitializedUseCase: CheckAccountInitializedUseCase = mockk() - private val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk() - private val getFeeUseCase: GetFeeUseCase = mockk() - private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase = mockk() - private val getActionRequirementAmountUseCase: GetActionRequirementAmountUseCase = mockk() - private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() - private val paramsInterceptorHolder: ParamsInterceptorHolder = mockk(relaxed = true) - private val shareManager: ShareManager = mockk() - private val urlOpener: UrlOpener = mockk() - private val coroutineScope: AppCoroutineScope = mockk() - private val innerRouter: InnerStakingRouter = mockk() - private val messageSender: UiMessageSender = mockk() - private val giveApprovalFeatureToggles: GiveApprovalFeatureToggles = mockk() - - @BeforeEach - fun setUp() { - MockKAnnotations.init(this) - - val (status, accountStatus) = createMockedAccountCurrencyStatus() - testCryptoCurrencyStatus = status - testAccountCurrencyStatus = accountStatus - - coEvery { getYieldUseCase(testIntegrationId.value) } returns Either.Right(testYield) - every { getSelectedAppCurrencyUseCase() } returns flowOf(Either.Right(AppCurrency.Default)) - every { stateController.uiState } returns MutableStateFlow(initialUiState) - every { stateController.initializeWithUserWallet(any()) } just Runs - every { stateController.updateAll(*anyVararg()) } just Runs - every { stateController.update(any>()) } just Runs - every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs - every { getUserWalletUseCase(testUserWalletId) } returns Either.Right(testUserWallet) - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - coEvery { checkAccountInitializedUseCase(testUserWalletId, any()) } returns true.right() - coEvery { isAnyTokenStakedUseCase(testUserWalletId) } returns Either.Right(false) - coEvery { - isAmountSubtractAvailableUseCase(testUserWalletId, any()) - } returns Either.Right(false) - every { getActionsUseCase(testUserWalletId, any()) } returns emptyFlow() - every { getBalanceHidingSettingsUseCase() } returns emptyFlow() - mockBalanceUpdater = mockk { - coEvery { partialUpdate() } just Runs - } - every { - stakingBalanceUpdater.create(any(), any(), any()) - } returns mockBalanceUpdater - } - - @Test - fun `GIVEN currency status emitted WHEN model created THEN analytics sent and fee status fetched`() = runTest { - val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() - every { testCryptoCurrencyStatus.value } returns mockk { - every { stakingBalance } returns mockk { - every { balance } returns YieldBalanceItem( - items = listOf( - mockk { every { validatorAddress } returns "address1" }, - mockk { every { validatorAddress } returns "address2" }, - ), - integrationId = "test" - ) - } - } - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - - val model = createModel(testScope = this) - advanceUntilIdle() - - verify { - paramsInterceptorHolder.addParamsInterceptor( - match { it.id() == "StakingParamsInterceptorId" } - ) - } - verify { - analyticsEventHandler.send( - StakingAnalyticsEvent.StakingInfoScreenOpened( - validatorsCount = 2 - ), - ) - } - verify { - stateController.initializeWithUserWallet(testUserWallet) - } - - model.onDestroy() - } - - @Test - fun `GIVEN currency status emitted twice WHEN model created THEN analytics sent only once`() = runTest { - val statusFlow = MutableSharedFlow() - val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns statusFlow - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - - val model = createModel(testScope = this) - statusFlow.emit(testAccountCurrencyStatus) - advanceUntilIdle() - statusFlow.emit(testAccountCurrencyStatus) - advanceUntilIdle() - - verify(exactly = 1) { - analyticsEventHandler.send( - event = StakingAnalyticsEvent.StakingInfoScreenOpened(validatorsCount = 0) - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN account initialized WHEN checkForTonHeatupCase THEN no error logged`() = runTest { - mockkObject(TangemLogger) - - val model = createModel(testScope = this) - advanceUntilIdle() - - coVerify { checkAccountInitializedUseCase(testUserWalletId, any()) } - verify(exactly = 0) { TangemLogger.e(any(), any()) } - - model.onDestroy() - unmockkObject(TangemLogger) - } - - @Test - fun `GIVEN checkAccountInitialized fails WHEN checkForTonHeatupCase THEN error logged`() = runTest { - val testError = RuntimeException("network error") - coEvery { - checkAccountInitializedUseCase(testUserWalletId, any()) - } returns Either.Left(testError) - mockkObject(TangemLogger) - every { TangemLogger.e(any(), any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - verify { TangemLogger.e("Error", testError) } - - model.onDestroy() - unmockkObject(TangemLogger) - } - - @Test - fun `GIVEN approval needed WHEN setupApprovalNeeded THEN getAllowanceUseCase called`() = runTest { - val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" - mockkObject(StakingIntegrationID.Companion) - every { - StakingIntegrationID.create(any()) - } returns mockk { - every { approval } returns StakingApproval.Needed(spenderAddress) - } - coEvery { - getAllowanceUseCase(testUserWalletId, any(), spenderAddress) - } returns Either.Right(BigDecimal.TEN) - - val model = createModel(testScope = this) - advanceUntilIdle() - - coVerify { - getAllowanceUseCase(testUserWalletId, any(), spenderAddress) - } - - model.onDestroy() - unmockkObject(StakingIntegrationID.Companion) - } - - @Test - fun `GIVEN approval needed AND getAllowance fails WHEN setupApprovalNeeded THEN no crash`() = runTest { - val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" - mockkObject(StakingIntegrationID.Companion) - every { - StakingIntegrationID.create(any()) - } returns mockk { - every { approval } returns StakingApproval.Needed(spenderAddress) - } - coEvery { - getAllowanceUseCase(testUserWalletId, any(), spenderAddress) - } returns Either.Left(RuntimeException("allowance error")) // error - - val model = createModel(testScope = this) - advanceUntilIdle() - - coVerify { - getAllowanceUseCase(testUserWalletId, any(), spenderAddress) - } - - model.onDestroy() - unmockkObject(StakingIntegrationID.Companion) - } - - @Test - fun `GIVEN any token staked WHEN setupIsAnyTokenStaked THEN use case called with correct wallet id`() = runTest { - coEvery { isAnyTokenStakedUseCase(testUserWalletId) } returns Either.Right(true) - - val model = createModel(testScope = this) - advanceUntilIdle() - - coVerify { isAnyTokenStakedUseCase(testUserWalletId) } - - model.onDestroy() - } - - @Test - fun `GIVEN subtract available WHEN checkIfSubtractAvailable THEN use case called with correct params`() = runTest { - coEvery { - isAmountSubtractAvailableUseCase(testUserWalletId, any()) - } returns Either.Right(true) - - val model = createModel(testScope = this) - advanceUntilIdle() - - coVerify { isAmountSubtractAvailableUseCase(testUserWalletId, any()) } - - model.onDestroy() - } - - @Test - fun `GIVEN actions emitted WHEN subscribeOnActionsUpdates AND isInitState THEN updateInitialData`() = runTest { - val testActions = listOf(mockk(relaxed = true)) - every { - getActionsUseCase(testUserWalletId, any()) - } returns flowOf(Either.Right(testActions)) - - val model = createModel(testScope = this) - advanceUntilIdle() - - verify(atLeast = 1) { - stateController.updateAll( - match { it is SetInitialDataStateTransformer }, - any(), - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN init step WHEN subscribeOnStepChanges THEN updateInitialData and partialUpdate called`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - verify(atLeast = 1) { - stateController.updateAll( - match { it is SetInitialDataStateTransformer }, - any(), - ) - } - coVerify { mockBalanceUpdater.partialUpdate() } - - model.onDestroy() - } - - @Test - fun `GIVEN assent step AND isWarning WHEN subscribeOnStepChanges THEN getFee AND amount rounded to integer`() = - runTest { - val uiStateFlow = MutableStateFlow(initialUiState) - every { stateController.uiState } returns uiStateFlow - every { - stakingOperationsFactory.createFeeLoader( - cryptoCurrencyStatus = any(), - userWallet = any(), - integration = any() - ) - } returns mockk { - coEvery { - getFee(any(), any(), any(), any()) - } just Runs - } - - val model = createModel(testScope = this) - advanceUntilIdle() - - val assentUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.Confirmation - every { confirmationState } returns mockk { - every { innerState } returns InnerConfirmationStakingState.ASSENT - } - every { amountState } returns mockk { - every { amountTextField } returns mockk { - every { isWarning } returns true - } - } - } - uiStateFlow.value = assentUiState - advanceUntilIdle() - - verify { - stateController.update( - match> { it is SetConfirmationStateLoadingTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN balance hidden WHEN subscribeOnBalanceHiding THEN HideBalanceStateTransformer applied`() = runTest { - val balanceHidingSettings = BalanceHidingSettings( - isHidingEnabledInSettings = true, - isBalanceHidden = true, - isBalanceHidingNotificationEnabled = false, - ) - every { getBalanceHidingSettingsUseCase() } returns flowOf(balanceHidingSettings) - - val model = createModel(testScope = this) - advanceUntilIdle() - - verify { - stateController.update( - match> { it is HideBalanceStateTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onDestroy THEN params interceptor removed`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onDestroy() - - verify { - paramsInterceptorHolder.removeParamsInterceptor("StakingParamsInterceptorId") - } - } - - @Test - fun `WHEN onBackClick THEN router pop and stateController clear called`() = runTest { - every { stateController.value } returns initialUiState - every { appRouter.pop(any()) } just Runs - every { stateController.clear() } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onBackClick() - - verify { appRouter.pop(any()) } - verify { stateController.clear() } - - model.onDestroy() - } - - @Test - fun `GIVEN targets AND no yield balance WHEN onNextClick with balance THEN validators unavailable alert sent`() = - runTest { - every { messageSender.send(any()) } just Runs - every { testYield.allValidatorsFull } returns true - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onNextClick(balanceState = null) - advanceUntilIdle() - - verify { - messageSender.send( - match { it is DialogMessage } // dialog from StakingModel.stakingEventFactory - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN partial amount disabled WHEN onNextClick with null balance THEN updateAll called with transformers`() = - runTest { - every { stateController.value } returns initialUiState - every { testYield.args.enter.isPartialAmountDisabled } returns true - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onNextClick(balanceState = null) - advanceUntilIdle() - - verify { - stateController.updateAll( - match { it is SetConfirmationStateInitTransformer }, - match { it is ValidatorSelectChangeTransformer }, - match { it is SetAmountDataTransformer }, - match { it is AmountMaxValueStateTransformer }, - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN non-initial step WHEN onNextClick THEN only stakingStateRouter onNextClick called`() = runTest { - val uiStateFlow = MutableStateFlow(initialUiState) - every { stateController.uiState } returns uiStateFlow - every { stateController.value } returns initialUiState - - val model = createModel(testScope = this) - advanceUntilIdle() - - val amountUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.Amount - } - uiStateFlow.value = amountUiState - every { stateController.value } returns amountUiState - advanceUntilIdle() - - clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) - - model.onNextClick(balanceState = null) - advanceUntilIdle() - - verify { - stateController.update(match<(StakingUiState) -> StakingUiState> { true }) - } - verify(exactly = 0) { - stateController.updateAll(*anyVararg()) - } - - model.onDestroy() - } - - @Test - fun `WHEN getFee THEN loading state set and feeLoader called`() = runTest { - val mockFeeLoader = mockk { - coEvery { - getFee(any(), any(), any(), any()) - } just Runs - } - every { - stakingOperationsFactory.createFeeLoader(any(), any(), any()) - } returns mockFeeLoader - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.getFee() - advanceUntilIdle() - - verify { - stateController.update( - transformer = match> { - it is SetConfirmationStateLoadingTransformer - } - ) - } - coVerify { - mockFeeLoader.getFee(any(), any(), any(), any()) - } - - model.onDestroy() - } - - @Test - fun `GIVEN StakeKit integration AND assent state WHEN onActionClick THEN sendTransaction called`() = runTest { - val uiStateFlow = MutableStateFlow(initialUiState) - every { stateController.uiState } returns uiStateFlow - val mockFeeLoader = mockk { - coEvery { - getFee(any(), any(), any(), any()) - } just Runs - } - every { - stakingOperationsFactory.createFeeLoader(any(), any(), any()) - } returns mockFeeLoader - val mockTransactionSender = mockk { - coEvery { send(any()) } just Runs - } - every { - stakingOperationsFactory.createTransactionSender( - cryptoCurrencyStatus = any(), - userWallet = any(), - integration = any(), - isAmountSubtractAvailable = any() - ) - } returns mockTransactionSender - - val model = createModel(testScope = this) - advanceUntilIdle() - - val assentUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.Confirmation - every { confirmationState } returns mockk { - every { innerState } returns InnerConfirmationStakingState.ASSENT - } - } - uiStateFlow.value = assentUiState - every { stateController.value } returns assentUiState - advanceUntilIdle() - - model.onActionClick() - advanceUntilIdle() - - verify { - stateController.update( - transformer = match> { - it is SetConfirmationStateInProgressTransformer - } - ) - } - coVerify { mockTransactionSender.send(any()) } - - model.onDestroy() - } - - @Test - fun `GIVEN P2PEthPool AND fee not increased WHEN onActionClick THEN sendTransaction called directly`() = runTest { - val p2pParams = StakingComponent.Params( - userWalletId = testUserWalletId, - cryptoCurrency = testCryptoCurrency, - integrationId = StakingIntegrationID.P2PEthPool, - ) - coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() - val uiStateFlow = MutableStateFlow(initialUiState) - every { stateController.uiState } returns uiStateFlow - coEvery { - getBalanceNotEnoughForFeeWarningUseCase( - fee = any(), - userWalletId = any(), - tokenStatus = any(), - feeStatus = any() - ) - } returns Either.Right(null) - coEvery { - getCurrencyCheckUseCase( - userWalletId = any(), - currencyStatus = any(), - feeCurrencyStatus = any(), - amount = any(), - fee = any(), - feeCurrencyBalanceAfterTransaction = any(), - recipientAddress = any() - ) - } returns mockk(relaxed = true) - val newFee = mockk(relaxed = true) { - every { amount.value } returns BigDecimal.ONE - } - val mockFeeLoader = mockk { - coEvery { - getFee(any(), any(), any(), any()) - } coAnswers { - firstArg<(Fee, Boolean) -> Unit>().invoke(newFee, false) - } - } - every { - stakingOperationsFactory.createFeeLoader(any(), any(), any()) - } returns mockFeeLoader - val mockTransactionSender = mockk { - coEvery { send(any()) } just Runs - } - every { - stakingOperationsFactory.createTransactionSender( - cryptoCurrencyStatus = any(), - userWallet = any(), - integration = any(), - isAmountSubtractAvailable = any() - ) - } returns mockTransactionSender - val currentFee = mockk(relaxed = true) { - every { amount.value } returns BigDecimal.TEN - } - val assentUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.Confirmation - every { confirmationState } returns mockk(relaxed = true) { - every { innerState } returns InnerConfirmationStakingState.ASSENT - every { feeState } returns mockk(relaxed = true) { - every { fee } returns currentFee - } - } - } - - val model = createModel( - paramsContainer = MutableParamsContainer(p2pParams), - testScope = this, - ) - advanceUntilIdle() - - uiStateFlow.value = assentUiState - every { stateController.value } returns assentUiState - advanceUntilIdle() - - model.onActionClick() - advanceUntilIdle() - - coVerify { mockTransactionSender.send(any()) } - verify(exactly = 0) { messageSender.send(match { it is DialogMessage }) } - - model.onDestroy() - } - - @Test - fun `GIVEN P2PEthPool AND fee increased WHEN onActionClick THEN fee updated alert shown`() = runTest { - val p2pParams = StakingComponent.Params( - userWalletId = testUserWalletId, - cryptoCurrency = testCryptoCurrency, - integrationId = StakingIntegrationID.P2PEthPool, - ) - coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() - val uiStateFlow = MutableStateFlow(initialUiState) - every { stateController.uiState } returns uiStateFlow - coEvery { - getBalanceNotEnoughForFeeWarningUseCase( - fee = any(), - userWalletId = any(), - tokenStatus = any(), - feeStatus = any() - ) - } returns Either.Right(null) - coEvery { - getCurrencyCheckUseCase( - any(), - any(), - any(), - any(), - any(), - any(), - any() - ) - } returns mockk(relaxed = true) - every { messageSender.send(any()) } just Runs - val newFee = mockk(relaxed = true) { - every { amount.value } returns BigDecimal.TEN - } - val mockFeeLoader = mockk { - coEvery { - getFee(any(), any(), any(), any()) - } coAnswers { - firstArg<(Fee, Boolean) -> Unit>().invoke(newFee, false) - } - } - every { - stakingOperationsFactory.createFeeLoader(any(), any(), any()) - } returns mockFeeLoader - val mockTransactionSender = mockk { - coEvery { send(any()) } just Runs - } - every { - stakingOperationsFactory.createTransactionSender( - cryptoCurrencyStatus = any(), - userWallet = any(), - integration = any(), - isAmountSubtractAvailable = any(), - ) - } returns mockTransactionSender - val currentFee = mockk(relaxed = true) { - every { amount.value } returns BigDecimal.ONE - } - val assentUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.Confirmation - every { confirmationState } returns mockk(relaxed = true) { - every { innerState } returns InnerConfirmationStakingState.ASSENT - every { feeState } returns mockk(relaxed = true) { - every { fee } returns currentFee - } - } - } - - val model = createModel( - paramsContainer = MutableParamsContainer(p2pParams), - testScope = this, - ) - advanceUntilIdle() - - uiStateFlow.value = assentUiState - every { stateController.value } returns assentUiState - advanceUntilIdle() - - model.onActionClick() - advanceUntilIdle() - - verify { - stateController.update( - match> { - it is SetConfirmationStateResetAssentTransformer - }, - ) - } - verify { messageSender.send(any()) } - coVerify(exactly = 0) { mockTransactionSender.send(any()) } - - model.onDestroy() - } - - @Test - fun `GIVEN assent state AND no approval in progress WHEN onPrevClick THEN prev navigated and assent reset`() = - runTest { - val uiStateFlow = MutableStateFlow(initialUiState) - every { stateController.uiState } returns uiStateFlow - every { - stakingOperationsFactory.createFeeLoader( - cryptoCurrencyStatus = any(), - userWallet = any(), - integration = any() - ) - } returns mockk { - coEvery { - getFee(any(), any(), any(), any()) - } just Runs - } - - val model = createModel(testScope = this) - advanceUntilIdle() - - val assentUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.Confirmation - every { confirmationState } returns mockk(relaxed = true) { - every { innerState } returns InnerConfirmationStakingState.ASSENT - every { notifications } returns persistentListOf() - } - } - uiStateFlow.value = assentUiState - every { stateController.value } returns assentUiState - advanceUntilIdle() - - clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) - every { stateController.update(any>()) } just Runs - every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs - every { stateController.value } returns assentUiState - - model.onPrevClick() - - verify { - stateController.update( - match> { - it is SetConfirmationStateResetAssentTransformer - }, - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN in progress state WHEN onPrevClick THEN nothing happens`() = runTest { - val uiStateFlow = MutableStateFlow(initialUiState) - every { stateController.uiState } returns uiStateFlow - every { - stakingOperationsFactory.createFeeLoader(any(), any(), any()) - } returns mockk { - coEvery { - getFee(any(), any(), any(), any()) - } just Runs - } - - val model = createModel(testScope = this) - advanceUntilIdle() - - val inProgressUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.Confirmation - every { confirmationState } returns mockk(relaxed = true) { - every { innerState } returns InnerConfirmationStakingState.IN_PROGRESS - } - } - uiStateFlow.value = inProgressUiState - every { stateController.value } returns inProgressUiState - advanceUntilIdle() - - clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) - - model.onPrevClick() - - verify(exactly = 0) { stateController.update(any>()) } - verify(exactly = 0) { stateController.update(any<(StakingUiState) -> StakingUiState>()) } - verify(exactly = 0) { stateController.updateAll(*anyVararg()) } - - model.onDestroy() - } - - @Test - fun `GIVEN completed state WHEN onPrevClick THEN onNextClick called`() = runTest { - val uiStateFlow = MutableStateFlow(initialUiState) - every { stateController.uiState } returns uiStateFlow - every { - stakingOperationsFactory.createFeeLoader(any(), any(), any()) - } returns mockk { - coEvery { - getFee(any(), any(), any(), any()) - } just Runs - } - - val model = createModel(testScope = this) - advanceUntilIdle() - - val completedUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.Confirmation - every { confirmationState } returns mockk(relaxed = true) { - every { innerState } returns InnerConfirmationStakingState.COMPLETED - } - } - uiStateFlow.value = completedUiState - every { stateController.value } returns completedUiState - advanceUntilIdle() - - clearMocks(stateController, answers = false, recordedCalls = true, verificationMarks = true) - every { stateController.value } returns completedUiState - every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs - every { stateController.clear() } just Runs - - model.onPrevClick() - advanceUntilIdle() - - verify { - stateController.update(any<(StakingUiState) -> StakingUiState>()) - } - - model.onDestroy() - } - - @Test - fun `GIVEN non-confirmation step WHEN onPrevClick THEN stakingStateRouter onPrevClick called`() = runTest { - val uiStateFlow = MutableStateFlow(initialUiState) - every { stateController.uiState } returns uiStateFlow - every { stateController.value } returns initialUiState - every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs - every { appRouter.pop(any()) } just Runs - every { stateController.clear() } just Runs - - val amountUiState = mockk(relaxed = true) { - every { currentStep } returns StakingStep.Amount - } - - val model = createModel(testScope = this) - advanceUntilIdle() - - uiStateFlow.value = amountUiState - every { stateController.value } returns amountUiState - every { stateController.uiState } returns MutableStateFlow(amountUiState) - - model.onPrevClick() - - verify { stateController.update(any<(StakingUiState) -> StakingUiState>()) } - - model.onDestroy() - } - - @Test - fun `WHEN onRefreshSwipe true THEN loading set and balanceUpdater partialUpdate called`() = runTest { - val testAppScope = object : AppCoroutineScope, - CoroutineScope by this {} - - val model = createModel( - testScope = this, - coroutineScope = testAppScope, - ) - advanceUntilIdle() - - model.onRefreshSwipe(isRefreshing = true) - advanceUntilIdle() - - verify { - stateController.update( - match> { - it is SetInitialLoadingStateTransformer - } - ) - } - coVerify { mockBalanceUpdater.partialUpdate() } - - model.onDestroy() - } - - @Test - fun `WHEN onInitialInfoBannerClick THEN analytics sent and url opened`() = runTest { - every { innerRouter.openUrl(any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onInitialInfoBannerClick() - - verify { - analyticsEventHandler.send(match { it is StakingAnalyticsEvent.WhatIsStaking }) - } - verify { - innerRouter.openUrl("https://tangem.com/en/blog/post/how-to-stake-cryptocurrency/") - } - - model.onDestroy() - } - - @Test - fun `WHEN onInfoClick THEN ShowInfoBottomSheetStateTransformer applied`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onInfoClick(InfoType.ANNUAL_PERCENTAGE_RATE) - - verify { - stateController.update( - match> { - it is ShowInfoBottomSheetStateTransformer - } - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN empty preferredTargets WHEN onAmountEnterClick THEN noAvailableValidators alert sent`() = runTest { - every { testYield.preferredValidators } returns emptyList() - every { messageSender.send(any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onAmountEnterClick() - - verify { messageSender.send(any()) } - - model.onDestroy() - } - - @Test - fun `GIVEN non-empty preferredTargets WHEN onAmountEnterClick THEN validator reset and onNextClick called`() = - runTest { - every { stateController.value } returns initialUiState - every { testYield.preferredValidators } returns listOf(mockk(relaxed = true)) - every { initialUiState.actionType } returns StakingActionCommonType.Enter(skipEnterAmount = false) - every { testYield.args.enter.isPartialAmountDisabled } returns true - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onAmountEnterClick() - advanceUntilIdle() - - verify { - stateController.updateAll( - match { it is ValidatorSelectChangeTransformer }, - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onAmountPasteTriggerDismiss THEN AmountPasteDismissStateTransformer applied`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onAmountPasteTriggerDismiss() - - verify { - stateController.update( - transformer = match> { it is AmountPasteDismissStateTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onMaxValueClick THEN analytics sent and AmountMaxValueStateTransformer applied`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onMaxValueClick() - - verify { - analyticsEventHandler.send( - match { - it is StakingAnalyticsEvent.ButtonMax - } - ) - } - verify { - stateController.update( - match> { it is AmountMaxValueStateTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onCurrencyChangeClick THEN analytics sent and AmountCurrencyChangeStateTransformer applied`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onCurrencyChangeClick(isFiat = true) - - verify { - analyticsEventHandler.send(match { it is StakingAnalyticsEvent.AmountSelectCurrency }) - } - verify { - stateController.update( - transformer = match> { it is AmountCurrencyChangeStateTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN openValidators THEN analytics sent and step changed to Validators`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.openValidators() - - verify { - analyticsEventHandler.send( - match { it is StakingAnalyticsEvent.ButtonValidator }, - ) - } - verify { - stateController.update(any<(StakingUiState) -> StakingUiState>()) - } - - model.onDestroy() - } - - @Test - fun `WHEN onTargetSelect THEN analytics sent and ValidatorSelectChangeTransformer applied`() = runTest { - val target: StakingTarget = mockk(relaxed = true) { - every { name } returns "TestValidator" - } - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onTargetSelect(target) - - verify { - analyticsEventHandler.send(event = StakingAnalyticsEvent.ValidatorChosen("TestValidator")) - } - verify { - stateController.update( - transformer = match> { it is ValidatorSelectChangeTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN RewardsRequirementsError AND minimumAmount WHEN openRewardsValidators THEN alert shown call`() = - runTest { - every { messageSender.send(any()) } just Runs - - val constraints = PendingActionConstraints( - type = StakingActionType.CLAIM_REWARDS, - amountArg = PendingAction.PendingActionArgs.Amount( - required = true, - minimum = BigDecimal.TEN, - maximum = null, - ), - ) - val yieldBalance = InnerYieldBalanceState.Data( - integrationId = "test-integration", - reward = YieldReward( - rewardsCrypto = "1.0", - rewardsFiat = "$1.00", - rewardBlockType = RewardBlockType.RewardsRequirementsError, - rewardConstraints = constraints, - ), - isActionable = true, - balances = persistentListOf(), - ) - val initialInfoState = mockk(relaxed = true) { - every { this@mockk.yieldBalance } returns yieldBalance - } - val uiState = mockk(relaxed = true) { - every { this@mockk.initialInfoState } returns initialInfoState - } - every { stateController.value } returns uiState - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.openRewardsValidators() - - verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } - verify { messageSender.send(any()) } - verify(exactly = 0) { getActionRequirementAmountUseCase.invoke(any(), any()) } - - model.onDestroy() - } - - @Test - fun `GIVEN RewardsRequirementsError WHEN openRewardsValidators THEN getActionRequirementAmountUseCase called`() = - runTest { - every { messageSender.send(any()) } just Runs - every { - getActionRequirementAmountUseCase.invoke(any(), any()) - } returns BigDecimal.ONE - - val yieldBalance = InnerYieldBalanceState.Data( - integrationId = "test-integration", - reward = YieldReward( - rewardsCrypto = "1.0", - rewardsFiat = "$1.00", - rewardBlockType = RewardBlockType.RewardsRequirementsError, - rewardConstraints = null, - ), - isActionable = true, - balances = persistentListOf(), - ) - val initialInfoState = mockk(relaxed = true) { - every { this@mockk.yieldBalance } returns yieldBalance - } - val uiState = mockk(relaxed = true) { - every { this@mockk.initialInfoState } returns initialInfoState - } - every { stateController.value } returns uiState - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.openRewardsValidators() - - verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } - verify { - getActionRequirementAmountUseCase.invoke( - integrationId = "test-integration", - actionType = StakingActionType.CLAIM_REWARDS - ) - } - verify { messageSender.send(any()) } - - model.onDestroy() - } - - @Test - fun `GIVEN no RewardsRequirementsError AND single reward WHEN openRewardsValidators THEN onActiveStake called`() = - runTest { - every { stateController.value } returns initialUiState - every { messageSender.send(any()) } just Runs - - val singleReward: BalanceState = mockk(relaxed = true) { - every { pendingActions } returns persistentListOf() - } - val yieldBalance = InnerYieldBalanceState.Data( - integrationId = "test-integration", - reward = YieldReward( - rewardsCrypto = "1.0", - rewardsFiat = "$1.00", - rewardBlockType = RewardBlockType.Rewards, - rewardConstraints = null, - ), - isActionable = true, - balances = persistentListOf(), - ) - val initialInfoState = mockk(relaxed = true) { - every { this@mockk.yieldBalance } returns yieldBalance - } - val rewardsValidatorsState = mockk(relaxed = true) { - every { rewards } returns persistentListOf(singleReward) - } - val uiState = mockk(relaxed = true) { - every { this@mockk.initialInfoState } returns initialInfoState - every { this@mockk.rewardsValidatorsState } returns rewardsValidatorsState - } - every { stateController.value } returns uiState - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.openRewardsValidators() - advanceUntilIdle() - - verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } - // onActiveStake path — ButtonValidator analytics should NOT be sent - verify(exactly = 0) { - analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonValidator }) - } - - model.onDestroy() - } - - @Test - fun `GIVEN no RewardsRequirementsError AND rewards WHEN openRewardsValidators THEN showRewardsValidators called`() = - runTest { - every { stateController.value } returns initialUiState - - val reward1: BalanceState = mockk(relaxed = true) - val reward2: BalanceState = mockk(relaxed = true) - val yieldBalance = InnerYieldBalanceState.Data( - integrationId = "test-integration", - reward = YieldReward( - rewardsCrypto = "2.0", - rewardsFiat = "$2.00", - rewardBlockType = RewardBlockType.Rewards, - rewardConstraints = null, - ), - isActionable = true, - balances = persistentListOf(), - ) - val initialInfoState = mockk(relaxed = true) { - every { this@mockk.yieldBalance } returns yieldBalance - } - val rewardsValidatorsState = mockk(relaxed = true) { - every { rewards } returns persistentListOf(reward1, reward2) - } - val uiState = mockk(relaxed = true) { - every { this@mockk.initialInfoState } returns initialInfoState - every { this@mockk.rewardsValidatorsState } returns rewardsValidatorsState - } - every { stateController.value } returns uiState - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.openRewardsValidators() - - verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } - verify { - analyticsEventHandler.send( - event = StakingAnalyticsEvent.ButtonValidator(source = StakeScreenSource.Info) - ) - } - verify { - stateController.update(any<(StakingUiState) -> StakingUiState>()) - } - - model.onDestroy() - } - - @Test - fun `GIVEN single pending action WHEN onActiveStake THEN prepareForConfirmation and onNextClick called`() = - runTest { - every { stateController.value } returns initialUiState - - val singleAction = PendingAction( - type = StakingActionType.CLAIM_REWARDS, - passthrough = "test", - args = null, - ) - val activeStake: BalanceState = mockk(relaxed = true) { - every { type } returns BalanceType.STAKED - every { pendingActions } returns persistentListOf(singleAction) - every { target } returns null - every { cryptoValue } returns "100" - } - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onActiveStake(activeStake) - advanceUntilIdle() - - // prepareForConfirmation calls updateAll with 4 transformers - verify { - stateController.updateAll( - match { it is SetConfirmationStateInitTransformer }, - match { it is ValidatorSelectChangeTransformer }, - match { it is SetAmountDataTransformer }, - any(), - ) - } - // onNextClick updates step - verify { stateController.update(any<(StakingUiState) -> StakingUiState>()) } - - model.onDestroy() - } - - @Test - fun `GIVEN multiple pending actions WHEN onActiveStake THEN ShowActionSelectorBottomSheetTransformer applied`() = - runTest { - every { stateController.value } returns initialUiState - - val action1 = PendingAction( - type = StakingActionType.CLAIM_REWARDS, - passthrough = "test1", - args = null, - ) - val action2 = PendingAction( - type = StakingActionType.WITHDRAW, - passthrough = "test2", - args = null, - ) - val activeStake: BalanceState = mockk(relaxed = true) { - every { type } returns BalanceType.STAKED - every { pendingActions } returns persistentListOf(action1, action2) - every { target } returns null - every { cryptoValue } returns "100" - } - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onActiveStake(activeStake) - - verify { - stateController.update( - match> { it is ShowActionSelectorBottomSheetTransformer }, - ) - } - // prepareForConfirmation should NOT have been called - verify(exactly = 0) { - stateController.updateAll( - match { it is SetConfirmationStateInitTransformer }, - any(), any(), any(), - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onActiveStakeAnalytic THEN ButtonValidator analytics sent`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onActiveStakeAnalytic() - - verify { - analyticsEventHandler.send( - StakingAnalyticsEvent.ButtonValidator( - source = StakeScreenSource.Info, - ) - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN gasless approval enabled WHEN showApprovalBottomSheet THEN approvalSlotNavigation activated`() = - runTest { - every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns true - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.showApprovalBottomSheet() - - verify(exactly = 0) { - stateController.update( - transformer = match> { it is ShowApprovalBottomSheetTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN gasless disabled WHEN showApprovalBottomSheet THEN ShowApprovalBottomSheetTransformer applied`() = - runTest { - every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns false - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.showApprovalBottomSheet() - - verify { - stateController.update( - transformer = match> { it is ShowApprovalBottomSheetTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onApproveTypeChange THEN SetApprovalBottomSheetTypeChangeTransformer applied`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onApproveTypeChange(ApproveType.LIMITED) - - verify { - stateController.update( - transformer = match> { it is SetApprovalBottomSheetTypeChangeTransformer }, - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN approval needed WHEN onApprovalClick THEN in progress set and createApprovalTransaction called`() = - runTest { - val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" - val expectedNetwork = mockk { - every { name } returns "KEK" - } - val testToken: CryptoCurrency.Token = mockk(relaxed = true) { - every { network } returns expectedNetwork - } - val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) { - every { currency } returns testToken - } - val testAccountCurrencyStatus = mockk { - every { component1() } returns mockk(relaxed = true) - every { component2() } returns testCryptoCurrencyStatus - } - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - - // Setup stakingApproval = Needed - mockkObject(StakingIntegrationID.Companion) - every { - StakingIntegrationID.create(any()) - } returns mockk { - every { approval } returns StakingApproval.Needed(spenderAddress) - } - coEvery { - getAllowanceUseCase(testUserWalletId, any(), spenderAddress) - } returns Either.Right(BigDecimal.TEN) - - every { - stakingOperationsFactory.createFeeLoader( - cryptoCurrencyStatus = any(), - userWallet = any(), - integration = any() - ) - } returns mockk { - coEvery { - getFee( - onStakingFee = any(), - onStakingFeeError = any(), - onApprovalFee = any(), - onFeeError = any() - ) - } just Runs - } - val expectedApprovalTx = Either.Right(mockk(relaxed = true)) - coEvery { - createApprovalTransactionUseCase.invoke( - cryptoCurrencyStatus = any(), - userWalletId = any(), - amount = any(), - fee = any(), - contractAddress = any(), - spenderAddress = any(), - ) - } returns expectedApprovalTx - coEvery { - sendTransactionUseCase(any(), any(), any()) - } returns Either.Right("txHash") - every { vibratorHapticManager.performOneTime(any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - // Now override stateController.value with confirmation state after cryptoCurrencyStatus is initialized - val testFee: Fee.Common = mockk(relaxed = true) - val confirmationState = mockk(relaxed = true) { - every { feeState } returns mockk(relaxed = true) { - every { fee } returns testFee - } - } - val uiState = mockk(relaxed = true) { - every { this@mockk.confirmationState } returns confirmationState - every { bottomSheetConfig } returns null - } - every { stateController.value } returns uiState - - model.onApprovalClick() - advanceUntilIdle() - - verify { - stateController.update( - transformer = match> { - it is SetApprovalBottomSheetInProgressTransformer - }, - ) - } - coVerify { - sendTransactionUseCase( - txData = expectedApprovalTx.value, - userWallet = testUserWallet, - network = expectedNetwork, - ) - } - - model.onDestroy() - unmockkObject(StakingIntegrationID.Companion) - } - - @Test - fun `WHEN onAmountReduceByClick THEN AmountReduceByStateTransformer and DismissNotification applied`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onAmountReduceByClick( - reduceAmountBy = BigDecimal.ONE, - reduceAmountByDiff = BigDecimal.TEN, - notification = NotificationUM::class.java, - ) - - verify { - stateController.update( - transformer = match> { it is AmountReduceByStateTransformer } - ) - } - verify { - stateController.update( - transformer = match> { it is DismissStakingNotificationsStateTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onAmountReduceToClick THEN AmountReduceToStateTransformer and DismissNotification applied`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onAmountReduceToClick( - reduceAmountTo = BigDecimal.ONE, - notification = NotificationUM::class.java, - ) - - verify { - stateController.update( - transformer = match> { it is AmountReduceToStateTransformer } - ) - } - verify { - stateController.update( - transformer = match> { - it is DismissStakingNotificationsStateTransformer - } - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onNotificationCancel THEN DismissStakingNotificationsStateTransformer applied`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onNotificationCancel(NotificationUM::class.java) - - verify { - stateController.update( - transformer = match> { it is DismissStakingNotificationsStateTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN txUrl not null WHEN onExploreClick THEN analytics sent and url opened`() = runTest { - val txUrl = "https://explorer.solana.com/tx/abc123" - val transactionDoneState = TransactionDoneState.Content( - timestamp = 1000L, - txUrl = txUrl, - ) - val confirmationState = mockk(relaxed = true) { - every { this@mockk.transactionDoneState } returns transactionDoneState - } - val uiState = mockk(relaxed = true) { - every { this@mockk.confirmationState } returns confirmationState - } - every { stateController.uiState } returns MutableStateFlow(uiState) - every { innerRouter.openUrl(any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onExploreClick() - - verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonExplore }) } - verify { innerRouter.openUrl(txUrl) } - - model.onDestroy() - } - - @Test - fun `GIVEN txUrl not null WHEN onShareClick THEN analytics sent and shareManager called`() = runTest { - val txUrl = "https://explorer.solana.com/tx/abc123" - val transactionDoneState = TransactionDoneState.Content( - timestamp = 1000L, - txUrl = txUrl, - ) - val confirmationState = mockk(relaxed = true) { - every { this@mockk.transactionDoneState } returns transactionDoneState - } - val uiState = mockk(relaxed = true) { - every { this@mockk.confirmationState } returns confirmationState - } - every { stateController.uiState } returns MutableStateFlow(uiState) - every { vibratorHapticManager.performOneTime(any()) } just Runs - every { shareManager.shareText(any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onShareClick() - - verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonShare }) } - verify { vibratorHapticManager.performOneTime(TangemHapticEffect.OneTime.Click) } - verify { shareManager.shareText(txUrl) } - - model.onDestroy() - } - - @Test - fun `WHEN onFailedTxEmailClick THEN analytics sent and sendFeedbackEmail called`() = runTest { - coEvery { getWalletMetaInfoUseCase(userWalletId = any()) } returns Either.Right(mockk(relaxed = true)) - every { saveBlockchainErrorUseCase(error = any()) } just Runs - coEvery { sendFeedbackEmailUseCase(type = any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onFailedTxEmailClick("test error") - advanceUntilIdle() - - verify { analyticsEventHandler.send(match { it is Basic.ButtonSupport }) } - coVerify { sendFeedbackEmailUseCase(match { it is FeedbackEmailType.StakingProblem }) } - - model.onDestroy() - } - - @Test - fun `WHEN openTokenDetails THEN innerRouter openTokenDetails called`() = runTest { - every { innerRouter.openTokenDetails(any(), any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - val currency: CryptoCurrency = mockk(relaxed = true) - model.openTokenDetails(currency) - - verify { innerRouter.openTokenDetails(testUserWalletId, currency) } - - model.onDestroy() - } - - @Test - fun `WHEN showPrimaryClickAlert THEN messageSender sends alert`() = runTest { - every { messageSender.send(any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.showPrimaryClickAlert() - - verify { messageSender.send(any()) } - - model.onDestroy() - } - - @Test - fun `WHEN onOpenLearnMoreAboutApproveClick THEN urlOpener opens approve url`() = runTest { - every { urlOpener.openUrl(any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onOpenLearnMoreAboutApproveClick() - - verify { urlOpener.openUrl("https://tangem.com/en/blog/post/give-revoke-permission/") } - - model.onDestroy() - } - - @Test - fun `GIVEN getFee returns Left WHEN onActivateTonAccountNotificationClick THEN fee error transformer applied`() = - runTest { - val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() - every { testCryptoCurrencyStatus.currency } returns mockk { - every { id } returns CryptoCurrency.ID.fromValue("coin⟨ethereum→-1843072795⟩ethereum") - every { symbol } returns "KEK" - every { network } returns mockk() - every { decimals } returns 2 - } - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - coEvery { - getNetworkAddressesUseCase.invokeSync( - userWalletId = any(), - network = any() - ) - } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) - coEvery { - createTransferTransactionUseCase( - amount = any(), memo = any(), destination = any(), - userWalletId = any(), network = any(), - ) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) - } returns Either.Left(mockk(relaxed = true)) - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onActivateTonAccountNotificationClick() - advanceUntilIdle() - - verify { - analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddressScreen("KEK")) - } - verify { - stateController.update( - transformer = match> { it is ShowTonInitializeBottomSheetTransformer } - ) - } - verify { - stateController.update( - transformer = match> { - it is SetFeeErrorToTonInitializeBottomSheetTransformer - }, - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN getFee returns Right WHEN onActivateTonAccountNotificationClick THEN fee transformer applied`() = - runTest { - val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() - every { testCryptoCurrencyStatus.currency } returns mockk { - every { id } returns CryptoCurrency.ID.fromValue("coin⟨ethereum→-1843072795⟩ethereum") - every { symbol } returns "SHMEK" - every { network } returns mockk() - every { decimals } returns 2 - } - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - coEvery { - getNetworkAddressesUseCase.invokeSync( - userWalletId = any(), - network = any() - ) - } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) - coEvery { - createTransferTransactionUseCase( - amount = any(), memo = any(), destination = any(), - userWalletId = any(), network = any(), - ) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) - } returns Either.Right(mockk(relaxed = true)) - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onActivateTonAccountNotificationClick() - advanceUntilIdle() - - verify { - analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddressScreen("SHMEK")) - } - verify { - stateController.update( - transformer = match> { it is ShowTonInitializeBottomSheetTransformer } - ) - } - verify { - stateController.update( - match> { - it is SetFeeToTonInitializeBottomSheetTransformer - }, - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onActivateTonAccountNotificationShow THEN UninitializedAddress analytics sent with token`() = runTest { - val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() - every { testCryptoCurrencyStatus.currency.symbol } returns "TON" - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onActivateTonAccountNotificationShow() - - verify { - analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddress(token = "TON")) - } - - model.onDestroy() - } - - @Test - fun `WHEN onNotEnoughFeeNotificationShow THEN NotEnoughFee analytics sent with token`() = runTest { - val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() - every { testCryptoCurrencyStatus.currency.symbol } returns "SOL" - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onNotEnoughFeeNotificationShow() - - verify { - analyticsEventHandler.send(StakingAnalyticsEvent.NotEnoughFee(token = "SOL")) - } - - model.onDestroy() - } - - @Test - fun `GIVEN sendTransaction returns Left WHEN onActivateTonAccountClick THEN error dialog sent`() = runTest { - val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() - every { testCryptoCurrencyStatus.currency.symbol } returns "TON" - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - coEvery { - getNetworkAddressesUseCase.invokeSync( - userWalletId = any(), - network = any() - ) - } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) - coEvery { - createTransferTransactionUseCase( - amount = any(), memo = any(), destination = any(), - userWalletId = any(), network = any(), - ) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - sendTransactionUseCase(any(), any(), any()) - } returns Either.Left(mockk(relaxed = true)) - every { messageSender.send(any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - // First populate tonAccountInitializeTransaction - model.onActivateTonAccountNotificationClick() - advanceUntilIdle() - - model.onActivateTonAccountClick() - advanceUntilIdle() - - verify { - analyticsEventHandler.send(StakingAnalyticsEvent.ButtonActivate(token = "TON")) - } - verify { messageSender.send(any()) } - - model.onDestroy() - } - - @Test - fun `GIVEN sendTransaction returns Right WHEN onActivateTonAccountClick THEN complete transformer applied`() = - runTest { - val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() - every { testCryptoCurrencyStatus.currency.symbol } returns "TON" - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - val mockBalanceUpdater: StakingBalanceUpdater = mockk { - coEvery { partialUpdate() } just Runs - coEvery { partialUpdateWithDelay() } just Runs - } - every { - stakingBalanceUpdater.create(any(), any(), any()) - } returns mockBalanceUpdater - coEvery { - getNetworkAddressesUseCase.invokeSync( - userWalletId = any(), - network = any() - ) - } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) - coEvery { - createTransferTransactionUseCase( - amount = any(), memo = any(), destination = any(), - userWalletId = any(), network = any(), - ) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - sendTransactionUseCase(any(), any(), any()) - } returns Either.Right("txHash") - - val model = createModel(testScope = this) - advanceUntilIdle() - - // First populate tonAccountInitializeTransaction - model.onActivateTonAccountNotificationClick() - advanceUntilIdle() - - model.onActivateTonAccountClick() - advanceUntilIdle() - - verify { - analyticsEventHandler.send(StakingAnalyticsEvent.ButtonActivate(token = "TON")) - } - verify { - stateController.update( - match> { it is CompleteInitializeBottomSheetTransformer }, - ) - } - coVerify { mockBalanceUpdater.partialUpdateWithDelay() } - - model.onDestroy() - } - - @Test - fun `WHEN onAmountReduceByFeeClick THEN AmountReduceByStateTransformer and DismissNotification applied`() = - runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onAmountReduceByFeeClick( - reduceAmount = BigDecimal.ONE, - notification = NotificationUM::class.java, - ) - - verify { - stateController.update( - transformer = match> { it is AmountReduceByStateTransformer } - ) - } - verify { - stateController.update( - transformer = match> { - it is DismissStakingNotificationsStateTransformer - } - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN approval needed AND amountState data WHEN getApprovalParams THEN returns non-null params`() = runTest { - val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" - - mockkObject(StakingIntegrationID.Companion) - try { - every { - StakingIntegrationID.create(any()) - } returns mockk { - every { approval } returns StakingApproval.Needed(spenderAddress) - } - coEvery { - getAllowanceUseCase(testUserWalletId, any(), spenderAddress) - } returns Either.Right(BigDecimal.TEN) - - val amountState = mockk(relaxed = true) { - every { amountTextField.value } returns "100" - } - val uiState = mockk(relaxed = true) { - every { this@mockk.amountState } returns amountState - } - every { stateController.value } returns uiState - - val model = createModel(testScope = this) - advanceUntilIdle() - - val result = model.getApprovalParams() - - assert(result != null) { "Expected non-null GiveApprovalComponent.Params" } - assert(result!!.spenderAddress == spenderAddress) { - "Expected spenderAddress=$spenderAddress, got=${result.spenderAddress}" - } - - model.onDestroy() - } finally { - unmockkObject(StakingIntegrationID.Companion) - } - } - - @Suppress("LongParameterList") - private fun createModel( - testScope: TestScope, - paramsContainer: ParamsContainer = MutableParamsContainer(testParams), - coroutineScope: AppCoroutineScope = this.coroutineScope, - ): StakingModel { - return StakingModel( - paramsContainer = paramsContainer, - stateController = stateController, - dispatchers = testScope.createTestingCoroutineDispatcherProvider(), - getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase, - getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, - getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase, - getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, - getUserWalletUseCase = getUserWalletUseCase, - sendTransactionUseCase = sendTransactionUseCase, - createApprovalTransactionUseCase = createApprovalTransactionUseCase, - getAllowanceUseCase = getAllowanceUseCase, - vibratorHapticManager = vibratorHapticManager, - getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, - saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, - getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, - getCurrencyCheckUseCase = getCurrencyCheckUseCase, - isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, - isAnyTokenStakedUseCase = isAnyTokenStakedUseCase, - invalidatePendingTransactionsUseCase = invalidatePendingTransactionsUseCase, - stakingOperationsFactory = stakingOperationsFactory, - stakingBalanceUpdater = stakingBalanceUpdater, - analyticsEventHandler = analyticsEventHandler, - sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, - getActionsUseCase = getActionsUseCase, - getYieldUseCase = getYieldUseCase, - p2pEthPoolRepository = p2pEthPoolRepository, - checkAccountInitializedUseCase = checkAccountInitializedUseCase, - createTransferTransactionUseCase = createTransferTransactionUseCase, - getFeeUseCase = getFeeUseCase, - getNetworkAddressesUseCase = getNetworkAddressesUseCase, - getActionRequirementAmountUseCase = getActionRequirementAmountUseCase, - isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, - getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase, - paramsInterceptorHolder = paramsInterceptorHolder, - shareManager = shareManager, - urlOpener = urlOpener, - coroutineScope = coroutineScope, - innerRouter = innerRouter, - messageSender = messageSender, - giveApprovalFeatureToggles = giveApprovalFeatureToggles, - appRouter = appRouter, - ) - } - - private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { - val testDispatcher = StandardTestDispatcher(testScheduler) - return TestingCoroutineDispatcherProvider( - main = testDispatcher, - mainImmediate = testDispatcher, - io = testDispatcher, - default = testDispatcher, - single = testDispatcher, - ) - } - - private fun createMockedAccountCurrencyStatus(): Pair { - val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) - val testAccountCurrencyStatus = mockk { - every { component1() } returns mockk(relaxed = true) - every { component2() } returns testCryptoCurrencyStatus - } - return testCryptoCurrencyStatus to testAccountCurrencyStatus - } -} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt new file mode 100644 index 0000000000..f44b1f3126 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt @@ -0,0 +1,228 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +import arrow.core.right +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.api.ParamsInterceptorHolder +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.haptic.VibratorHapticManager +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.models.currency.CryptoCurrency +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.staking.* +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.domain.staking.model.stakekit.Yield +import com.tangem.domain.staking.repositories.P2PEthPoolRepository +import com.tangem.domain.tokens.* +import com.tangem.domain.transaction.usecase.* +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.approval.api.GiveApprovalFeatureToggles +import com.tangem.features.staking.api.StakingComponent +import com.tangem.features.staking.impl.navigation.InnerStakingRouter +import com.tangem.features.staking.impl.presentation.state.StakingStateController +import com.tangem.features.staking.impl.presentation.state.StakingStep +import com.tangem.features.staking.impl.presentation.state.StakingUiState +import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater +import com.tangem.features.staking.impl.presentation.state.helpers.StakingOperationsFactory +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import org.junit.jupiter.api.BeforeEach + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class StakingModelTestBase { + + protected val testUserWalletId = UserWalletId("1234567890ABCDEF") + protected val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) + private val testIntegrationId = StakingIntegrationID.StakeKit.Coin.Solana + private val testParams = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = testIntegrationId, + ) + protected val testYield: Yield = mockk(relaxed = true) + protected val testUserWallet: UserWallet = mockk(relaxed = true) + protected val initialUiState: StakingUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.InitialInfo + } + + private lateinit var testCryptoCurrencyStatus: CryptoCurrencyStatus + private lateinit var testAccountCurrencyStatus: AccountCryptoCurrencyStatus + protected lateinit var mockBalanceUpdater: StakingBalanceUpdater + + protected val stateController: StakingStateController = mockk() + private val getYieldUseCase: GetYieldUseCase = mockk() + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val getUserWalletUseCase: GetUserWalletUseCase = mockk() + protected val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk() + protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + protected val appRouter: AppRouter = mockk() + + protected val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk() + protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk() + protected val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase = mockk() + protected val sendTransactionUseCase: SendTransactionUseCase = mockk() + protected val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk() + protected val getAllowanceUseCase: GetAllowanceUseCase = mockk() + protected val vibratorHapticManager: VibratorHapticManager = mockk() + protected val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk() + protected val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk() + protected val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk() + protected val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() + protected val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() + protected val isAnyTokenStakedUseCase: IsAnyTokenStakedUseCase = mockk() + private val invalidatePendingTransactionsUseCase: InvalidatePendingTransactionsUseCase = mockk() + protected val stakingOperationsFactory: StakingOperationsFactory = mockk() + protected val stakingBalanceUpdater: StakingBalanceUpdater.Factory = mockk() + protected val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk() + protected val getActionsUseCase: GetActionsUseCase = mockk() + protected val p2pEthPoolRepository: P2PEthPoolRepository = mockk() + protected val checkAccountInitializedUseCase: CheckAccountInitializedUseCase = mockk() + protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk() + protected val getFeeUseCase: GetFeeUseCase = mockk() + protected val getNetworkAddressesUseCase: GetNetworkAddressesUseCase = mockk() + protected val getActionRequirementAmountUseCase: GetActionRequirementAmountUseCase = mockk() + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() + protected val paramsInterceptorHolder: ParamsInterceptorHolder = mockk(relaxed = true) + protected val shareManager: ShareManager = mockk() + protected val urlOpener: UrlOpener = mockk() + private val coroutineScope: AppCoroutineScope = mockk() + protected val innerRouter: InnerStakingRouter = mockk() + protected val messageSender: UiMessageSender = mockk() + protected val giveApprovalFeatureToggles: GiveApprovalFeatureToggles = mockk() + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + + val (status, accountStatus) = createMockedAccountCurrencyStatus() + testCryptoCurrencyStatus = status + testAccountCurrencyStatus = accountStatus + + coEvery { getYieldUseCase(testIntegrationId.value) } returns Either.Right(testYield) + every { getSelectedAppCurrencyUseCase() } returns flowOf(Either.Right(AppCurrency.Default)) + every { stateController.uiState } returns MutableStateFlow(initialUiState) + every { stateController.initializeWithUserWallet(any()) } just Runs + every { stateController.updateAll(*anyVararg()) } just Runs + every { stateController.update(any>()) } just Runs + every { stateController.update(any<(StakingUiState) -> StakingUiState>()) } just Runs + every { getUserWalletUseCase(testUserWalletId) } returns Either.Right(testUserWallet) + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { checkAccountInitializedUseCase(testUserWalletId, any()) } returns true.right() + coEvery { isAnyTokenStakedUseCase(testUserWalletId) } returns Either.Right(false) + coEvery { + isAmountSubtractAvailableUseCase(testUserWalletId, any()) + } returns Either.Right(false) + every { getActionsUseCase(testUserWalletId, any()) } returns emptyFlow() + every { getBalanceHidingSettingsUseCase() } returns emptyFlow() + mockBalanceUpdater = mockk { + coEvery { partialUpdate() } just Runs + } + every { + stakingBalanceUpdater.create(any(), any(), any()) + } returns mockBalanceUpdater + } + + @Suppress("LongParameterList") + protected fun createModel( + testScope: TestScope, + paramsContainer: ParamsContainer = MutableParamsContainer(testParams), + coroutineScope: AppCoroutineScope = this.coroutineScope, + ): StakingModel { + return StakingModel( + paramsContainer = paramsContainer, + stateController = stateController, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getUserWalletUseCase = getUserWalletUseCase, + sendTransactionUseCase = sendTransactionUseCase, + createApprovalTransactionUseCase = createApprovalTransactionUseCase, + getAllowanceUseCase = getAllowanceUseCase, + vibratorHapticManager = vibratorHapticManager, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, + getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, + getCurrencyCheckUseCase = getCurrencyCheckUseCase, + isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, + isAnyTokenStakedUseCase = isAnyTokenStakedUseCase, + invalidatePendingTransactionsUseCase = invalidatePendingTransactionsUseCase, + stakingOperationsFactory = stakingOperationsFactory, + stakingBalanceUpdater = stakingBalanceUpdater, + analyticsEventHandler = analyticsEventHandler, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + getActionsUseCase = getActionsUseCase, + getYieldUseCase = getYieldUseCase, + p2pEthPoolRepository = p2pEthPoolRepository, + checkAccountInitializedUseCase = checkAccountInitializedUseCase, + createTransferTransactionUseCase = createTransferTransactionUseCase, + getFeeUseCase = getFeeUseCase, + getNetworkAddressesUseCase = getNetworkAddressesUseCase, + getActionRequirementAmountUseCase = getActionRequirementAmountUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase, + paramsInterceptorHolder = paramsInterceptorHolder, + shareManager = shareManager, + urlOpener = urlOpener, + coroutineScope = coroutineScope, + innerRouter = innerRouter, + messageSender = messageSender, + giveApprovalFeatureToggles = giveApprovalFeatureToggles, + appRouter = appRouter, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + protected fun createMockedAccountCurrencyStatus(): Pair { + val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) + val testAccountCurrencyStatus = mockk { + every { component1() } returns mockk(relaxed = true) + every { component2() } returns testCryptoCurrencyStatus + } + return testCryptoCurrencyStatus to testAccountCurrencyStatus + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt new file mode 100644 index 0000000000..2a40d90554 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt @@ -0,0 +1,792 @@ +package com.tangem.features.staking.impl.presentation.model + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.bottomsheet.permission.state.ApproveType +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +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.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.StakingApproval +import com.tangem.domain.staking.model.StakingIntegrationID +import com.tangem.features.staking.api.StakingComponent +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.helpers.StakingBalanceUpdater +import com.tangem.features.staking.impl.presentation.state.helpers.StakingFeeLoader +import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransactionSender +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateInProgressTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateLoadingTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateResetAssentTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetInProgressTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetTypeChangeTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.approval.ShowApprovalBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.CompleteInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeErrorToTonInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeToTonInitializeBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ton.ShowTonInitializeBottomSheetTransformer +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelTransactionTest : StakingModelTestBase() { + + @Test + fun `WHEN getFee THEN loading state set and feeLoader called`() = runTest { + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.getFee() + advanceUntilIdle() + + verify { + stateController.update( + transformer = match> { + it is SetConfirmationStateLoadingTransformer + } + ) + } + coVerify { + mockFeeLoader.getFee(any(), any(), any(), any()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN StakeKit integration AND assent state WHEN onActionClick THEN sendTransaction called`() = runTest { + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } just Runs + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + val mockTransactionSender = mockk { + coEvery { send(any()) } just Runs + } + every { + stakingOperationsFactory.createTransactionSender( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any(), + isAmountSubtractAvailable = any() + ) + } returns mockTransactionSender + + val model = createModel(testScope = this) + advanceUntilIdle() + + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk { + every { innerState } returns InnerConfirmationStakingState.ASSENT + } + } + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + model.onActionClick() + advanceUntilIdle() + + verify { + stateController.update( + transformer = match> { + it is SetConfirmationStateInProgressTransformer + } + ) + } + coVerify { mockTransactionSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN P2PEthPool AND fee not increased WHEN onActionClick THEN sendTransaction called directly`() = runTest { + val p2pParams = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = StakingIntegrationID.P2PEthPool, + ) + coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + coEvery { + getBalanceNotEnoughForFeeWarningUseCase( + fee = any(), + userWalletId = any(), + tokenStatus = any(), + feeStatus = any() + ) + } returns Either.Right(null) + coEvery { + getCurrencyCheckUseCase( + userWalletId = any(), + currencyStatus = any(), + feeCurrencyStatus = any(), + amount = any(), + fee = any(), + feeCurrencyBalanceAfterTransaction = any(), + recipientAddress = any() + ) + } returns mockk(relaxed = true) + val newFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.ONE + } + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } coAnswers { + firstArg<(Fee, Boolean) -> Unit>().invoke(newFee, false) + } + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + val mockTransactionSender = mockk { + coEvery { send(any()) } just Runs + } + every { + stakingOperationsFactory.createTransactionSender( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any(), + isAmountSubtractAvailable = any() + ) + } returns mockTransactionSender + val currentFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.TEN + } + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.ASSENT + every { feeState } returns mockk(relaxed = true) { + every { fee } returns currentFee + } + } + } + + val model = createModel( + paramsContainer = MutableParamsContainer(p2pParams), + testScope = this, + ) + advanceUntilIdle() + + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + model.onActionClick() + advanceUntilIdle() + + coVerify { mockTransactionSender.send(any()) } + verify(exactly = 0) { messageSender.send(match { it is DialogMessage }) } + + model.onDestroy() + } + + @Test + fun `GIVEN P2PEthPool AND fee increased WHEN onActionClick THEN fee updated alert shown`() = runTest { + val p2pParams = StakingComponent.Params( + userWalletId = testUserWalletId, + cryptoCurrency = testCryptoCurrency, + integrationId = StakingIntegrationID.P2PEthPool, + ) + coEvery { p2pEthPoolRepository.getVaultsSync() } returns emptyList() + val uiStateFlow = MutableStateFlow(initialUiState) + every { stateController.uiState } returns uiStateFlow + coEvery { + getBalanceNotEnoughForFeeWarningUseCase( + fee = any(), + userWalletId = any(), + tokenStatus = any(), + feeStatus = any() + ) + } returns Either.Right(null) + coEvery { + getCurrencyCheckUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns mockk(relaxed = true) + every { messageSender.send(any()) } just Runs + val newFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.TEN + } + val mockFeeLoader = mockk { + coEvery { + getFee(any(), any(), any(), any()) + } coAnswers { + firstArg<(Fee, Boolean) -> Unit>().invoke(newFee, false) + } + } + every { + stakingOperationsFactory.createFeeLoader(any(), any(), any()) + } returns mockFeeLoader + val mockTransactionSender = mockk { + coEvery { send(any()) } just Runs + } + every { + stakingOperationsFactory.createTransactionSender( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any(), + isAmountSubtractAvailable = any(), + ) + } returns mockTransactionSender + val currentFee = mockk(relaxed = true) { + every { amount.value } returns BigDecimal.ONE + } + val assentUiState = mockk(relaxed = true) { + every { currentStep } returns StakingStep.Confirmation + every { confirmationState } returns mockk(relaxed = true) { + every { innerState } returns InnerConfirmationStakingState.ASSENT + every { feeState } returns mockk(relaxed = true) { + every { fee } returns currentFee + } + } + } + + val model = createModel( + paramsContainer = MutableParamsContainer(p2pParams), + testScope = this, + ) + advanceUntilIdle() + + uiStateFlow.value = assentUiState + every { stateController.value } returns assentUiState + advanceUntilIdle() + + model.onActionClick() + advanceUntilIdle() + + verify { + stateController.update( + match> { + it is SetConfirmationStateResetAssentTransformer + }, + ) + } + verify { messageSender.send(any()) } + coVerify(exactly = 0) { mockTransactionSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN gasless approval enabled WHEN showApprovalBottomSheet THEN approvalSlotNavigation activated`() = + runTest { + every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns true + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.showApprovalBottomSheet() + + verify(exactly = 0) { + stateController.update( + transformer = match> { it is ShowApprovalBottomSheetTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN gasless disabled WHEN showApprovalBottomSheet THEN ShowApprovalBottomSheetTransformer applied`() = + runTest { + every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns false + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.showApprovalBottomSheet() + + verify { + stateController.update( + transformer = match> { it is ShowApprovalBottomSheetTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onApproveTypeChange THEN SetApprovalBottomSheetTypeChangeTransformer applied`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onApproveTypeChange(ApproveType.LIMITED) + + verify { + stateController.update( + transformer = match> { it is SetApprovalBottomSheetTypeChangeTransformer }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN approval needed WHEN onApprovalClick THEN in progress set and createApprovalTransaction called`() = + runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + val expectedNetwork = mockk { + every { name } returns "KEK" + } + val testToken: CryptoCurrency.Token = mockk(relaxed = true) { + every { network } returns expectedNetwork + } + val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) { + every { currency } returns testToken + } + val testAccountCurrencyStatus = mockk { + every { component1() } returns mockk(relaxed = true) + every { component2() } returns testCryptoCurrencyStatus + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + // Setup stakingApproval = Needed + mockkObject(StakingIntegrationID.Companion) + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Right(BigDecimal.TEN) + + every { + stakingOperationsFactory.createFeeLoader( + cryptoCurrencyStatus = any(), + userWallet = any(), + integration = any() + ) + } returns mockk { + coEvery { + getFee( + onStakingFee = any(), + onStakingFeeError = any(), + onApprovalFee = any(), + onFeeError = any() + ) + } just Runs + } + val expectedApprovalTx = Either.Right(mockk(relaxed = true)) + coEvery { + createApprovalTransactionUseCase.invoke( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = any(), + fee = any(), + contractAddress = any(), + spenderAddress = any(), + ) + } returns expectedApprovalTx + coEvery { + sendTransactionUseCase(any(), any(), any()) + } returns Either.Right("txHash") + every { vibratorHapticManager.performOneTime(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + // Now override stateController.value with confirmation state after cryptoCurrencyStatus is initialized + val testFee: Fee.Common = mockk(relaxed = true) + val confirmationState = mockk(relaxed = true) { + every { feeState } returns mockk(relaxed = true) { + every { fee } returns testFee + } + } + val uiState = mockk(relaxed = true) { + every { this@mockk.confirmationState } returns confirmationState + every { bottomSheetConfig } returns null + } + every { stateController.value } returns uiState + + model.onApprovalClick() + advanceUntilIdle() + + verify { + stateController.update( + transformer = match> { + it is SetApprovalBottomSheetInProgressTransformer + }, + ) + } + coVerify { + sendTransactionUseCase( + txData = expectedApprovalTx.value, + userWallet = testUserWallet, + network = expectedNetwork, + ) + } + + model.onDestroy() + unmockkObject(StakingIntegrationID.Companion) + } + + @Test + fun `GIVEN approval needed AND amountState data WHEN getApprovalParams THEN returns non-null params`() = runTest { + val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" + + mockkObject(StakingIntegrationID.Companion) + try { + every { + StakingIntegrationID.create(any()) + } returns mockk { + every { approval } returns StakingApproval.Needed(spenderAddress) + } + coEvery { + getAllowanceUseCase(testUserWalletId, any(), spenderAddress) + } returns Either.Right(BigDecimal.TEN) + + val amountState = mockk(relaxed = true) { + every { amountTextField.value } returns "100" + } + val uiState = mockk(relaxed = true) { + every { this@mockk.amountState } returns amountState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + val result = model.getApprovalParams() + + assert(result != null) { "Expected non-null GiveApprovalComponent.Params" } + assert(result!!.spenderAddress == spenderAddress) { + "Expected spenderAddress=$spenderAddress, got=${result.spenderAddress}" + } + + model.onDestroy() + } finally { + unmockkObject(StakingIntegrationID.Companion) + } + } + + @Test + fun `GIVEN getFee returns Left WHEN onActivateTonAccountNotificationClick THEN fee error transformer applied`() = + runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency } returns mockk { + every { id } returns CryptoCurrency.ID.fromValue("coin⟨ethereum→-1843072795⟩ethereum") + every { symbol } returns "KEK" + every { network } returns mockk() + every { decimals } returns 2 + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Left(mockk(relaxed = true)) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddressScreen("KEK")) + } + verify { + stateController.update( + transformer = match> { it is ShowTonInitializeBottomSheetTransformer } + ) + } + verify { + stateController.update( + transformer = match> { + it is SetFeeErrorToTonInitializeBottomSheetTransformer + }, + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN getFee returns Right WHEN onActivateTonAccountNotificationClick THEN fee transformer applied`() = + runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency } returns mockk { + every { id } returns CryptoCurrency.ID.fromValue("coin⟨ethereum→-1843072795⟩ethereum") + every { symbol } returns "SHMEK" + every { network } returns mockk() + every { decimals } returns 2 + } + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Right(mockk(relaxed = true)) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddressScreen("SHMEK")) + } + verify { + stateController.update( + transformer = match> { it is ShowTonInitializeBottomSheetTransformer } + ) + } + verify { + stateController.update( + match> { + it is SetFeeToTonInitializeBottomSheetTransformer + }, + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onActivateTonAccountNotificationShow THEN UninitializedAddress analytics sent with token`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "TON" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActivateTonAccountNotificationShow() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.UninitializedAddress(token = "TON")) + } + + model.onDestroy() + } + + @Test + fun `WHEN onNotEnoughFeeNotificationShow THEN NotEnoughFee analytics sent with token`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "SOL" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onNotEnoughFeeNotificationShow() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.NotEnoughFee(token = "SOL")) + } + + model.onDestroy() + } + + @Test + fun `GIVEN sendTransaction returns Left WHEN onActivateTonAccountClick THEN error dialog sent`() = runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "TON" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + sendTransactionUseCase(any(), any(), any()) + } returns Either.Left(mockk(relaxed = true)) + every { messageSender.send(any()) } just Runs + + val model = createModel(testScope = this) + advanceUntilIdle() + + // First populate tonAccountInitializeTransaction + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + model.onActivateTonAccountClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.ButtonActivate(token = "TON")) + } + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN sendTransaction returns Right WHEN onActivateTonAccountClick THEN complete transformer applied`() = + runTest { + val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() + every { testCryptoCurrencyStatus.currency.symbol } returns "TON" + every { + getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) + } returns flowOf(testAccountCurrencyStatus) + coEvery { + getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) + } returns Either.Left(mockk()) + val mockBalanceUpdater: StakingBalanceUpdater = mockk { + coEvery { partialUpdate() } just Runs + coEvery { partialUpdateWithDelay() } just Runs + } + every { + stakingBalanceUpdater.create(any(), any(), any()) + } returns mockBalanceUpdater + coEvery { + getNetworkAddressesUseCase.invokeSync( + userWalletId = any(), + network = any() + ) + } returns listOf(mockk(relaxed = true) { every { address } returns "TON_ADDRESS" }) + coEvery { + createTransferTransactionUseCase( + amount = any(), memo = any(), destination = any(), + userWalletId = any(), network = any(), + ) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + getFeeUseCase(userWallet = any(), network = any(), transactionData = any()) + } returns Either.Right(mockk(relaxed = true)) + coEvery { + sendTransactionUseCase(any(), any(), any()) + } returns Either.Right("txHash") + + val model = createModel(testScope = this) + advanceUntilIdle() + + // First populate tonAccountInitializeTransaction + model.onActivateTonAccountNotificationClick() + advanceUntilIdle() + + model.onActivateTonAccountClick() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(StakingAnalyticsEvent.ButtonActivate(token = "TON")) + } + verify { + stateController.update( + match> { it is CompleteInitializeBottomSheetTransformer }, + ) + } + coVerify { mockBalanceUpdater.partialUpdateWithDelay() } + + model.onDestroy() + } +} \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt new file mode 100644 index 0000000000..8a891e3781 --- /dev/null +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt @@ -0,0 +1,351 @@ +package com.tangem.features.staking.impl.presentation.model + +import com.tangem.domain.models.staking.BalanceType +import com.tangem.domain.models.staking.PendingAction +import com.tangem.domain.models.staking.PendingActionConstraints +import com.tangem.domain.models.staking.RewardBlockType +import com.tangem.domain.models.staking.action.StakingActionType +import com.tangem.domain.staking.analytics.StakeScreenSource +import com.tangem.domain.staking.analytics.StakingAnalyticsEvent +import com.tangem.domain.staking.model.StakingTarget +import com.tangem.features.staking.impl.presentation.state.* +import com.tangem.features.staking.impl.presentation.state.transformers.SetAmountDataTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateInitTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.ShowActionSelectorBottomSheetTransformer +import com.tangem.features.staking.impl.presentation.state.transformers.validator.ValidatorSelectChangeTransformer +import com.tangem.utils.transformer.Transformer +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class StakingModelValidatorTest : StakingModelTestBase() { + + @Test + fun `WHEN openValidators THEN analytics sent and step changed to Validators`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openValidators() + + verify { + analyticsEventHandler.send( + match { it is StakingAnalyticsEvent.ButtonValidator }, + ) + } + verify { + stateController.update(any<(StakingUiState) -> StakingUiState>()) + } + + model.onDestroy() + } + + @Test + fun `WHEN onTargetSelect THEN analytics sent and ValidatorSelectChangeTransformer applied`() = runTest { + val target: StakingTarget = mockk(relaxed = true) { + every { name } returns "TestValidator" + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onTargetSelect(target) + + verify { + analyticsEventHandler.send(event = StakingAnalyticsEvent.ValidatorChosen("TestValidator")) + } + verify { + stateController.update( + transformer = match> { it is ValidatorSelectChangeTransformer } + ) + } + + model.onDestroy() + } + + @Test + fun `GIVEN no RewardsRequirementsError AND single reward WHEN openRewardsValidators THEN onActiveStake called`() = + runTest { + every { stateController.value } returns initialUiState + every { messageSender.send(any()) } just Runs + + val singleReward: BalanceState = mockk(relaxed = true) { + every { pendingActions } returns persistentListOf() + } + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "1.0", + rewardsFiat = "$1.00", + rewardBlockType = RewardBlockType.Rewards, + rewardConstraints = null, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val rewardsValidatorsState = mockk(relaxed = true) { + every { rewards } returns persistentListOf(singleReward) + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + every { this@mockk.rewardsValidatorsState } returns rewardsValidatorsState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + // onActiveStake path — ButtonValidator analytics should NOT be sent + verify(exactly = 0) { + analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonValidator }) + } + + model.onDestroy() + } + + @Test + fun `GIVEN no RewardsRequirementsError AND rewards WHEN openRewardsValidators THEN showRewardsValidators called`() = + runTest { + every { stateController.value } returns initialUiState + + val reward1: BalanceState = mockk(relaxed = true) + val reward2: BalanceState = mockk(relaxed = true) + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "2.0", + rewardsFiat = "$2.00", + rewardBlockType = RewardBlockType.Rewards, + rewardConstraints = null, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val rewardsValidatorsState = mockk(relaxed = true) { + every { rewards } returns persistentListOf(reward1, reward2) + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + every { this@mockk.rewardsValidatorsState } returns rewardsValidatorsState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + verify { + analyticsEventHandler.send( + event = StakingAnalyticsEvent.ButtonValidator(source = StakeScreenSource.Info) + ) + } + verify { + stateController.update(any<(StakingUiState) -> StakingUiState>()) + } + + model.onDestroy() + } + + @Test + fun `GIVEN RewardsRequirementsError AND minimumAmount WHEN openRewardsValidators THEN alert shown call`() = + runTest { + every { messageSender.send(any()) } just Runs + + val constraints = PendingActionConstraints( + type = StakingActionType.CLAIM_REWARDS, + amountArg = PendingAction.PendingActionArgs.Amount( + required = true, + minimum = BigDecimal.TEN, + maximum = null, + ), + ) + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "1.0", + rewardsFiat = "$1.00", + rewardBlockType = RewardBlockType.RewardsRequirementsError, + rewardConstraints = constraints, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + verify { messageSender.send(any()) } + verify(exactly = 0) { getActionRequirementAmountUseCase.invoke(any(), any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN RewardsRequirementsError WHEN openRewardsValidators THEN getActionRequirementAmountUseCase called`() = + runTest { + every { messageSender.send(any()) } just Runs + every { + getActionRequirementAmountUseCase.invoke(any(), any()) + } returns BigDecimal.ONE + + val yieldBalance = InnerYieldBalanceState.Data( + integrationId = "test-integration", + reward = YieldReward( + rewardsCrypto = "1.0", + rewardsFiat = "$1.00", + rewardBlockType = RewardBlockType.RewardsRequirementsError, + rewardConstraints = null, + ), + isActionable = true, + balances = persistentListOf(), + ) + val initialInfoState = mockk(relaxed = true) { + every { this@mockk.yieldBalance } returns yieldBalance + } + val uiState = mockk(relaxed = true) { + every { this@mockk.initialInfoState } returns initialInfoState + } + every { stateController.value } returns uiState + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.openRewardsValidators() + + verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.ButtonRewards }) } + verify { + getActionRequirementAmountUseCase.invoke( + integrationId = "test-integration", + actionType = StakingActionType.CLAIM_REWARDS + ) + } + verify { messageSender.send(any()) } + + model.onDestroy() + } + + @Test + fun `GIVEN single pending action WHEN onActiveStake THEN prepareForConfirmation and onNextClick called`() = + runTest { + every { stateController.value } returns initialUiState + + val singleAction = PendingAction( + type = StakingActionType.CLAIM_REWARDS, + passthrough = "test", + args = null, + ) + val activeStake: BalanceState = mockk(relaxed = true) { + every { type } returns BalanceType.STAKED + every { pendingActions } returns persistentListOf(singleAction) + every { target } returns null + every { cryptoValue } returns "100" + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActiveStake(activeStake) + advanceUntilIdle() + + // prepareForConfirmation calls updateAll with 4 transformers + verify { + stateController.updateAll( + match { it is SetConfirmationStateInitTransformer }, + match { it is ValidatorSelectChangeTransformer }, + match { it is SetAmountDataTransformer }, + any(), + ) + } + // onNextClick updates step + verify { stateController.update(any<(StakingUiState) -> StakingUiState>()) } + + model.onDestroy() + } + + @Test + fun `GIVEN multiple pending actions WHEN onActiveStake THEN ShowActionSelectorBottomSheetTransformer applied`() = + runTest { + every { stateController.value } returns initialUiState + + val action1 = PendingAction( + type = StakingActionType.CLAIM_REWARDS, + passthrough = "test1", + args = null, + ) + val action2 = PendingAction( + type = StakingActionType.WITHDRAW, + passthrough = "test2", + args = null, + ) + val activeStake: BalanceState = mockk(relaxed = true) { + every { type } returns BalanceType.STAKED + every { pendingActions } returns persistentListOf(action1, action2) + every { target } returns null + every { cryptoValue } returns "100" + } + + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActiveStake(activeStake) + + verify { + stateController.update( + match> { it is ShowActionSelectorBottomSheetTransformer }, + ) + } + // prepareForConfirmation should NOT have been called + verify(exactly = 0) { + stateController.updateAll( + match { it is SetConfirmationStateInitTransformer }, + any(), any(), any(), + ) + } + + model.onDestroy() + } + + @Test + fun `WHEN onActiveStakeAnalytic THEN ButtonValidator analytics sent`() = runTest { + val model = createModel(testScope = this) + advanceUntilIdle() + + model.onActiveStakeAnalytic() + + verify { + analyticsEventHandler.send( + StakingAnalyticsEvent.ButtonValidator( + source = StakeScreenSource.Info, + ) + ) + } + + model.onDestroy() + } +} \ No newline at end of file From 4cf6ae6dbb0d336aa05c7ffd34f8121c603a95f4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Apr 2026 11:50:19 +0200 Subject: [PATCH 058/206] Updated on 2026-08-14 --- .../features/swap/v2/impl/amount/model/SwapAmountModel.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index ecc249a62b..d4a45ef016 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -123,6 +123,7 @@ internal class SwapAmountModel @Inject constructor( private var lastAmountScreenOpenedCurrencyId: CryptoCurrency.ID? = null private var autoUpdateSubscriberJob: Job? = null + private var navigationJob: Job? = null val uiState: StateFlow field = MutableStateFlow(params.amountUM) @@ -137,7 +138,6 @@ internal class SwapAmountModel @Inject constructor( ?: UserCountry.Other(Locale.getDefault().country) isShowBestRateAnimation = swapBestRateAnimationStore.getSyncOrNull() } - configAmountNavigation() subscribeOnCryptoCurrencyStatusFlow() subscribeOnAmountUpdateTriggerUpdates() observeChooseSelectToken() @@ -149,6 +149,7 @@ internal class SwapAmountModel @Inject constructor( } fun onStart() { + configAmountNavigation() quoteTaskScheduler.scheduleTask( scope = modelScope, task = loadQuotesTask(), @@ -159,6 +160,7 @@ internal class SwapAmountModel @Inject constructor( fun onStop() { quoteTaskScheduler.cancelTask() autoUpdateSubscriberJob?.cancel() + navigationJob?.cancel() } override fun onDestroy() { @@ -871,7 +873,8 @@ internal class SwapAmountModel @Inject constructor( private fun configAmountNavigation() { val params = params as? SwapAmountComponentParams.AmountParams ?: return - combine( + navigationJob?.cancel() + navigationJob = combine( flow = uiState, flow2 = params.currentRoute, transform = { state, route -> state to route }, From e88adfe4315241585db463395343a80b4c28f4d4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 16 Apr 2026 14:38:05 +0100 Subject: [PATCH 059/206] Updated on 2026-08-14 --- .../tangem/feature/tester/presentation/TesterActivity.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 76d4e77f3a..2be8ce7d0e 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -80,7 +80,11 @@ internal class TesterActivity : ComposeActivity() { private fun TesterNavHost() { val navController = rememberNavController().also { innerTesterRouter.setNavController(it) } - NavHost(navController = navController, startDestination = TesterScreen.MENU.name) { + NavHost( + modifier = Modifier.systemBarsPadding(), + navController = navController, + startDestination = TesterScreen.MENU.name + ) { composable(route = TesterScreen.MENU.name) { TesterMenuScreen( state = TesterMenuUM( @@ -114,7 +118,6 @@ internal class TesterActivity : ComposeActivity() { innerTesterRouter.open(route) }, ), - modifier = Modifier.systemBarsPadding(), ) } From 2b383418c0b3686c568a8a510bdce8222c7a4a1e Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Apr 2026 08:35:50 +0100 Subject: [PATCH 060/206] Updated on 2026-08-14 --- .../com/tangem/feature/tester/presentation/TesterActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt index 2be8ce7d0e..7ed452749d 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/TesterActivity.kt @@ -83,7 +83,7 @@ internal class TesterActivity : ComposeActivity() { NavHost( modifier = Modifier.systemBarsPadding(), navController = navController, - startDestination = TesterScreen.MENU.name + startDestination = TesterScreen.MENU.name, ) { composable(route = TesterScreen.MENU.name) { TesterMenuScreen( From 1326b5bab379756fe79993651d46ca23215c002f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Apr 2026 15:32:56 +0400 Subject: [PATCH 061/206] Updated on 2026-08-14 --- .../di/domain/WalletConnectDomainModule.kt | 30 +++ .../configs/feature_toggles_config.json | 4 + core/res/src/main/res/values/strings.xml | 2 + .../blockaid/DefaultBlockAidRepository.kt | 12 + data/wallet-connect/build.gradle.kts | 1 + .../di/WalletConnectDataModule.kt | 67 +++++- .../DefaultWalletConnectFeatureToggles.kt | 13 ++ .../initialize/DefaultWcInitializeUseCase.kt | 2 +- .../walletconnect/network/bitcoin/Model.kt | 112 +++++++++ .../WcBitcoinGetAccountAddressesUseCase.kt | 118 ++++++++++ .../network/bitcoin/WcBitcoinNetwork.kt | 214 ++++++++++++++++++ .../bitcoin/WcBitcoinSendTransferUseCase.kt | 146 ++++++++++++ .../bitcoin/WcBitcoinSignMessageUseCase.kt | 117 ++++++++++ .../bitcoin/WcBitcoinSignPsbtUseCase.kt | 142 ++++++++++++ .../network/bitcoin/WcBitcoinTxAction.kt | 8 + .../solana/SolanaBlockAidAddressConverter.kt | 2 +- .../pair/DefaultWcPairUseCase.kt | 2 +- .../walletconnect/pair/WcPairSdkDelegate.kt | 2 +- .../request/DefaultWcRequestService.kt | 9 +- .../request/DefaultWcRequestUseCaseFactory.kt | 2 +- .../respond/DefaultWcRespondService.kt | 2 +- .../sessions/DefaultWcSessionsManager.kt | 2 +- .../sign/BlockAidChainNameConverter.kt | 3 + .../utils/BlockAidVerificationDelegate.kt | 27 ++- .../data/walletconnect/utils/WcSdkObserver.kt | 2 - .../models/transaction/TransactionData.kt | 9 + .../walletconnect/model/WcBitcoinMethod.kt | 93 ++++++++ .../tangem/domain/walletconnect/WcLogTag.kt | 6 + .../WcTransactionSignerProvider.kt | 21 ++ .../WalletConnectFeatureToggles.kt | 5 + .../usecase/method/WcGetAddressesUseCase.kt | 41 ++++ .../routing/DefaultWcRoutingComponent.kt | 5 + .../connections/routing/WcInnerRoute.kt | 3 + .../connections/routing/WcRoutingModel.kt | 39 +++- .../di/WalletConnectModelModule.kt | 6 + .../addresses/WcGetAddressesComponent.kt | 25 ++ .../converter/TransactionParamsConverter.kt | 12 +- .../converter/WcSendTransactionUMConverter.kt | 4 + .../entity/addresses/WcGetAddressesUM.kt | 23 ++ .../transaction/model/WcGetAddressesModel.kt | 46 ++++ .../model/WcSignTransactionModel.kt | 40 +++- 41 files changed, 1381 insertions(+), 38 deletions(-) create mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/featuretoggle/DefaultWalletConnectFeatureToggles.kt create mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/Model.kt create mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinGetAccountAddressesUseCase.kt create mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinNetwork.kt create mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSendTransferUseCase.kt create mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignMessageUseCase.kt create mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignPsbtUseCase.kt create mode 100644 data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinTxAction.kt create mode 100644 domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcBitcoinMethod.kt create mode 100644 domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcLogTag.kt create mode 100644 domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcTransactionSignerProvider.kt create mode 100644 domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/featuretoggle/WalletConnectFeatureToggles.kt create mode 100644 domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcGetAddressesUseCase.kt create mode 100644 features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/addresses/WcGetAddressesComponent.kt create mode 100644 features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt create mode 100644 features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcGetAddressesModel.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt index fd5d2491d3..780bea965e 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletConnectDomainModule.kt @@ -1,6 +1,13 @@ package com.tangem.tap.di.domain +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.data.wallets.hot.TangemHotWalletSigner +import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins +import com.tangem.domain.card.models.TwinKey +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase +import com.tangem.domain.walletconnect.WcTransactionSignerProvider import com.tangem.domain.walletconnect.repository.WalletConnectRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.walletconnect.usecase.WcSessionsUseCase @@ -27,4 +34,27 @@ internal object WalletConnectDomainModule { fun providesWcSessionsUseCase(sessionsManager: WcSessionsManager): WcSessionsUseCase { return WcSessionsUseCase(sessionsManager) } + + @Provides + @Singleton + fun providesWcTransactionSignerProvider( + cardSdkConfigRepository: CardSdkConfigRepository, + tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, + ): WcTransactionSignerProvider { + return object : WcTransactionSignerProvider { + override fun createSigner(wallet: UserWallet): TransactionSigner { + return when (wallet) { + is UserWallet.Hot -> tangemHotWalletSignerFactory.create(wallet) + is UserWallet.Cold -> { + val card = wallet.scanResponse.card + val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins + cardSdkConfigRepository.getCommonSigner( + cardId = card.cardId.takeIf { isCardNotBackedUp }, + twinKey = TwinKey.getOrNull(scanResponse = wallet.scanResponse), + ) + } + } + } + } + } } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 214bc42fee..37b1b10533 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -59,5 +59,9 @@ { "name": "ADD_AND_MANAGE_TOKENS_ENABLED", "version": "undefined" + }, + { + "name": "WALLET_CONNECT_BITCOIN_ENABLED", + "version": "undefined" } ] diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 4f79cdf591..6321abad93 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -2150,6 +2150,8 @@ At least one network is required for dApp connection Specify selected networks Successfully signed + Share Addresses + Addresses to share WalletConnect To Transaction request diff --git a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt index 59cf796d98..a5da6c9298 100644 --- a/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt +++ b/data/blockaid/src/main/kotlin/com/tangem/data/blockaid/DefaultBlockAidRepository.kt @@ -33,6 +33,7 @@ internal class DefaultBlockAidRepository( return when (data.params) { is TransactionParams.Evm -> scanEvmTransaction(data = data) is TransactionParams.Solana -> scanSolanaTransaction(data = data) + is TransactionParams.Bitcoin -> scanBitcoinTransaction(data = data) } } @@ -59,6 +60,17 @@ internal class DefaultBlockAidRepository( mapper.mapToDomain(response) } + @Suppress("UnusedParameter") + private fun scanBitcoinTransaction(data: TransactionData): CheckTransactionResult { + // TODO: BlockAid API doesn't support Bitcoin transaction scanning yet + // When support is added, implement: api.scanBitcoinTransaction(mapper.mapToBitcoinRequest(data)) + return CheckTransactionResult( + validation = com.domain.blockaid.models.transaction.ValidationResult.FAILED_TO_VALIDATE, + description = "Bitcoin transaction validation is not yet supported by BlockAid", + simulation = com.domain.blockaid.models.transaction.SimulationResult.FailedToSimulate, + ) + } + private suspend fun scanEvmTransactionBulk( blockchain: Blockchain, transactionDataList: List, diff --git a/data/wallet-connect/build.gradle.kts b/data/wallet-connect/build.gradle.kts index b7fd3d93a1..46be3e797d 100644 --- a/data/wallet-connect/build.gradle.kts +++ b/data/wallet-connect/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { /* Project - Core */ implementation(projects.core.utils) implementation(projects.core.analytics) + api(projects.core.configToggles) /* DI */ implementation(deps.hilt.core) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt index dc5c0bb1ba..59d3fc8014 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/di/WalletConnectDataModule.kt @@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.walletconnect.DefaultWalletConnectRepository import com.tangem.data.walletconnect.initialize.DefaultWcInitializeUseCase +import com.tangem.data.walletconnect.network.bitcoin.WcBitcoinNetwork import com.tangem.data.walletconnect.network.ethereum.WcEthNetwork import com.tangem.data.walletconnect.network.solana.WcSolanaNetwork import com.tangem.data.walletconnect.pair.* @@ -25,6 +26,7 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.WcRequestService import com.tangem.domain.walletconnect.WcRequestUseCaseFactory +import com.tangem.domain.walletconnect.featuretoggle.WalletConnectFeatureToggles import com.tangem.domain.walletconnect.repository.WalletConnectRepository import com.tangem.domain.walletconnect.repository.WcSessionsManager import com.tangem.domain.walletconnect.usecase.disconnect.WcDisconnectUseCase @@ -34,6 +36,8 @@ import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.data.walletconnect.featuretoggle.DefaultWalletConnectFeatureToggles import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -162,6 +166,22 @@ internal object WalletConnectDataModule { networksConverter = wcNetworksConverter, ) + @Provides + @Singleton + fun wcBitcoinNetwork( + @SdkMoshi moshi: Moshi, + wcNetworksConverter: WcNetworksConverter, + sessionsManager: WcSessionsManager, + factories: WcBitcoinNetwork.Factories, + walletManagersFacade: WalletManagersFacade, + ): WcBitcoinNetwork = WcBitcoinNetwork( + moshi = moshi, + sessionsManager = sessionsManager, + factories = factories, + networksConverter = wcNetworksConverter, + walletManagersFacade = walletManagersFacade, + ) + @Provides @Singleton fun caipNamespaceDelegate( @@ -198,11 +218,19 @@ internal object WalletConnectDataModule { @Provides @Singleton - fun diHelperBox(ethNetwork: WcEthNetwork, solanaNetwork: WcSolanaNetwork) = DiHelperBox( - handlers = setOf( - ethNetwork, - solanaNetwork, - ), + fun diHelperBox( + ethNetwork: WcEthNetwork, + solanaNetwork: WcSolanaNetwork, + bitcoinNetwork: WcBitcoinNetwork, + featureToggles: WalletConnectFeatureToggles, + ) = DiHelperBox( + handlers = buildSet { + add(ethNetwork) + add(solanaNetwork) + if (featureToggles.isBitcoinEnabled) { + add(bitcoinNetwork) + } + }, ) @Provides @@ -210,10 +238,15 @@ internal object WalletConnectDataModule { fun namespaceConverters( ethNamespaceConverter: WcEthNetwork.NamespaceConverter, solanaNamespaceConverter: WcSolanaNetwork.NamespaceConverter, - ): Set<@JvmSuppressWildcards WcNamespaceConverter> = setOf( - ethNamespaceConverter, - solanaNamespaceConverter, - ) + bitcoinNamespaceConverter: WcBitcoinNetwork.NamespaceConverter, + featureToggles: WalletConnectFeatureToggles, + ): Set<@JvmSuppressWildcards WcNamespaceConverter> = buildSet { + add(ethNamespaceConverter) + add(solanaNamespaceConverter) + if (featureToggles.isBitcoinEnabled) { + add(bitcoinNamespaceConverter) + } + } @Provides @Singleton @@ -239,6 +272,14 @@ internal object WalletConnectDataModule { return WcSolanaNetwork.NamespaceConverter(excludedBlockchains) } + @Provides + @Singleton + fun wcBitcoinNetworkNamespaceConverter( + excludedBlockchains: ExcludedBlockchains, + ): WcBitcoinNetwork.NamespaceConverter { + return WcBitcoinNetwork.NamespaceConverter(excludedBlockchains) + } + @Provides @Singleton fun providesWcDisconnectUseCase( @@ -248,6 +289,14 @@ internal object WalletConnectDataModule { return WcDisconnectUseCase(sessionsManager, analytics) } + @Provides + @Singleton + fun providesWalletConnectFeatureToggles( + featureTogglesManager: FeatureTogglesManager, + ): WalletConnectFeatureToggles { + return DefaultWalletConnectFeatureToggles(featureTogglesManager) + } + internal class DiHelperBox( val handlers: Set, ) diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/featuretoggle/DefaultWalletConnectFeatureToggles.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/featuretoggle/DefaultWalletConnectFeatureToggles.kt new file mode 100644 index 0000000000..1f8d8184a4 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/featuretoggle/DefaultWalletConnectFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.data.walletconnect.featuretoggle + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.domain.walletconnect.featuretoggle.WalletConnectFeatureToggles + +internal class DefaultWalletConnectFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : WalletConnectFeatureToggles { + + override val isBitcoinEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.WALLET_CONNECT_BITCOIN_ENABLED) +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt index 13522ffe02..00dab5a545 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/initialize/DefaultWcInitializeUseCase.kt @@ -9,7 +9,7 @@ import com.reown.walletkit.client.WalletKit import com.tangem.data.walletconnect.pair.WcPairSdkDelegate import com.tangem.data.walletconnect.request.DefaultWcRequestService import com.tangem.data.walletconnect.sessions.DefaultWcSessionsManager -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.utils.logging.TangemLogger diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/Model.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/Model.kt new file mode 100644 index 0000000000..2cabcd17c7 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/Model.kt @@ -0,0 +1,112 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * JSON request model for sendTransfer method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinSendTransferRequest( + @Json(name = "account") + val account: String, + + @Json(name = "recipientAddress") + val recipientAddress: String, + + @Json(name = "amount") + val amount: String, + + @Json(name = "memo") + val memo: String? = null, + + @Json(name = "changeAddress") + val changeAddress: String? = null, +) + +/** + * JSON request model for getAccountAddresses method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinGetAccountAddressesRequest( + @Json(name = "account") + val account: String? = null, + + @Json(name = "intentions") + val intentions: List? = null, +) + +/** + * JSON request model for signPsbt method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinSignPsbtRequest( + @Json(name = "psbt") + val psbt: String, + + @Json(name = "signInputs") + val signInputs: List, + + @Json(name = "broadcast") + val isBroadcast: Boolean? = false, +) + +/** + * JSON model for sign input specification. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinSignInput( + @Json(name = "address") + val address: String, + + @Json(name = "index") + val index: Int, + + @Json(name = "sighashTypes") + val sighashTypes: List? = null, +) + +/** + * JSON request model for signMessage method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinSignMessageRequest( + @Json(name = "account") + val account: String, + + @Json(name = "message") + val message: String, + + @Json(name = "address") + val address: String? = null, + + @Json(name = "protocol") + val protocol: String? = "ecdsa", +) + +/** + * JSON response model for getAccountAddresses method. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinGetAccountAddressesResponse( + @Json(name = "addresses") + val addresses: List, +) + +/** + * Address information in getAccountAddresses response. + */ +@JsonClass(generateAdapter = true) +internal data class WcBitcoinAddressInfo( + @Json(name = "address") + val address: String, + + @Json(name = "publicKey") + val publicKey: String? = null, + + @Json(name = "path") + val path: String? = null, + + @Json(name = "intention") + val intention: String? = null, +) \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinGetAccountAddressesUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinGetAccountAddressesUseCase.kt new file mode 100644 index 0000000000..8b182c1263 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinGetAccountAddressesUseCase.kt @@ -0,0 +1,118 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.AccountAddress +import com.tangem.blockchain.extensions.Result as SdkResult +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.datasource.di.SdkMoshi +import com.tangem.domain.models.network.Network +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.model.WcSession +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.usecase.method.WcGetAddressesUseCase +import com.tangem.domain.walletconnect.usecase.method.WcNetworkDerivationState +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Use case for Bitcoin getAccountAddresses WalletConnect method. + * + * Returns wallet addresses filtered by intention (payment/ordinal). + * This is a non-signing operation. + */ +@JsonClass(generateAdapter = true) +internal data class AddressInfo( + @Json(name = "address") val address: String, + @Json(name = "publicKey") val publicKey: String? = null, + @Json(name = "path") val path: String? = null, + @Json(name = "intention") val intention: String? = null, +) + +internal class WcBitcoinGetAccountAddressesUseCase @AssistedInject constructor( + @Assisted val context: WcMethodUseCaseContext, + @Assisted override val method: WcBitcoinMethod.GetAccountAddresses, + private val walletManagersFacade: WalletManagersFacade, + private val respondService: WcRespondService, + @SdkMoshi private val moshi: Moshi, +) : WcGetAddressesUseCase { + + override val wallet get() = context.session.wallet + + override val session: WcSession + get() = context.session + override val rawSdkRequest: WcSdkSessionRequest + get() = context.rawSdkRequest + override val network: Network + get() = context.network + override val derivationState: WcNetworkDerivationState = when { + context.networkDerivationsCount > 1 -> WcNetworkDerivationState.Multiple(walletAddress = context.accountAddress) + else -> WcNetworkDerivationState.Single + } + + override suspend fun invoke(): Either { + val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network) + ?: return HandleMethodError.UnknownError("Failed to create wallet manager").left() + return when (val result = walletManager.getAddresses(filterOptions = method.intentions)) { + is SdkResult.Success -> { + val accountAddresses = result.data.map { addressInfo -> + AccountAddress( + address = addressInfo.address, + publicKey = addressInfo.publicKey, + path = addressInfo.derivationPath, + intention = addressInfo.metadata?.get("intention") as? String, + ) + } + val response = buildJsonResponse(accountAddresses) + respondService.respond(rawSdkRequest, response) + WcGetAddressesUseCase.GetAddressesResult( + addresses = accountAddresses.map { addr -> + WcGetAddressesUseCase.AddressInfo( + address = addr.address, + publicKey = addr.publicKey, + path = addr.path, + intention = addr.intention, + ) + }, + ).right() + } + is SdkResult.Failure -> { + HandleMethodError.UnknownError(result.error.customMessage).left() + } + } + } + + override fun reject() { + respondService.rejectRequestNonBlock(rawSdkRequest) + } + + private fun buildJsonResponse(accountAddresses: List): String { + val addresses = accountAddresses.map { addr -> + AddressInfo( + address = addr.address, + publicKey = addr.publicKey, + path = addr.path, + intention = addr.intention, + ) + } + return moshi.adapter>( + com.squareup.moshi.Types.newParameterizedType(List::class.java, AddressInfo::class.java), + ).toJson(addresses) + } + + @AssistedFactory + interface Factory { + fun create( + context: WcMethodUseCaseContext, + method: WcBitcoinMethod.GetAccountAddresses, + ): WcBitcoinGetAccountAddressesUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinNetwork.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinNetwork.kt new file mode 100644 index 0000000000..38c35fbeed --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinNetwork.kt @@ -0,0 +1,214 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.right +import com.squareup.moshi.Moshi +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.data.walletconnect.model.CAIP2 +import com.tangem.data.walletconnect.model.NamespaceKey +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter +import com.tangem.data.walletconnect.request.WcRequestToUseCaseConverter.Companion.fromJson +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.data.walletconnect.utils.WcNamespaceConverter +import com.tangem.data.walletconnect.utils.WcNetworksConverter +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.model.WcBitcoinMethodName +import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest +import com.tangem.domain.walletconnect.repository.WcSessionsManager +import com.tangem.domain.walletconnect.usecase.method.WcMethodUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import jakarta.inject.Inject + +/** + * WalletConnect request handler for Bitcoin blockchain. + * + * Handles Bitcoin-specific RPC methods: sendTransfer, getAccountAddresses, signPsbt, signMessage. + * + * @see Bitcoin RPC Reference + */ +internal class WcBitcoinNetwork( + private val moshi: Moshi, + private val sessionsManager: WcSessionsManager, + private val factories: Factories, + private val networksConverter: WcNetworksConverter, + private val walletManagersFacade: WalletManagersFacade, +) : WcRequestToUseCaseConverter { + + override fun toWcMethodName(request: WcSdkSessionRequest): WcBitcoinMethodName? { + val methodKey = request.request.method + return WcBitcoinMethodName.entries.find { it.raw == methodKey } + } + + @Suppress("CyclomaticComplexMethod") + override suspend fun toUseCase(request: WcSdkSessionRequest): Either { + fun error(message: String) = HandleMethodError.UnknownError(message).left() + + val name = toWcMethodName(request) ?: return error("Unknown method name") + val method: WcBitcoinMethod = name.toMethod(request) + .getOrElse { return error(it.message.orEmpty()) } + ?: return error("Failed to parse $name") + + val session = sessionsManager.findSessionByTopic(request.topic) + ?: return HandleMethodError.UnknownSession.left() + + val wallet = session.wallet + val chainId = request.chainId.orEmpty() + + val account = session.account + + suspend fun anyExistNetwork() = networksConverter.mainOrAnyWalletNetworkForRequest( + rawChainId = chainId, + account = account, + ) + + suspend fun anyAddress() = anyExistNetwork() + ?.let { network -> walletManagersFacade.getDefaultAddress(wallet.walletId, network).orEmpty() } + .orEmpty() + + val accountAddress = when (method) { + is WcBitcoinMethod.SendTransfer -> method.account + is WcBitcoinMethod.GetAccountAddresses -> method.account + is WcBitcoinMethod.SignPsbt -> method.signInputs.firstOrNull()?.address ?: anyAddress() + is WcBitcoinMethod.SignMessage -> method.address ?: method.account + } + + val walletNetwork = networksConverter + .findWalletNetworkForRequest(request, session, accountAddress) + ?: anyExistNetwork() + ?: return error("Failed to find walletNetwork for accountAddress $accountAddress") + + val context = WcMethodUseCaseContext( + session = session, + rawSdkRequest = request, + network = walletNetwork, + accountAddress = accountAddress, + networkDerivationsCount = networksConverter.filterWalletNetworkForRequest( + rawChainId = chainId, + account = account, + ).size, + ) + + val useCase = when (method) { + is WcBitcoinMethod.SendTransfer -> factories.sendTransfer.create(context, method) + is WcBitcoinMethod.GetAccountAddresses -> factories.getAccountAddresses.create(context, method) + is WcBitcoinMethod.SignPsbt -> factories.signPsbt.create(context, method) + is WcBitcoinMethod.SignMessage -> factories.signMessage.create(context, method) + } + return useCase.right() + } + + private fun WcBitcoinMethodName.toMethod(request: WcSdkSessionRequest): Either { + val rawParams = request.request.params + return when (this) { + WcBitcoinMethodName.SendTransfer -> moshi.fromJson(rawParams) + .getOrElse { return it.left() } + ?.let { req -> + WcBitcoinMethod.SendTransfer( + account = req.account, + recipientAddress = req.recipientAddress, + amount = req.amount, + memo = req.memo, + changeAddress = req.changeAddress, + ) + } + WcBitcoinMethodName.GetAccountAddresses -> moshi.fromJson(rawParams) + .getOrElse { return it.left() } + ?.let { req -> + WcBitcoinMethod.GetAccountAddresses( + account = req.account.orEmpty(), + intentions = req.intentions, + ) + } + WcBitcoinMethodName.SignPsbt -> moshi.fromJson(rawParams) + .getOrElse { return it.left() } + ?.let { req -> + WcBitcoinMethod.SignPsbt( + psbt = req.psbt, + signInputs = req.signInputs.map { input -> + WcBitcoinMethod.SignInput( + address = input.address, + index = input.index, + sighashTypes = input.sighashTypes, + ) + }, + shouldBroadcast = req.isBroadcast == true, + ) + } + WcBitcoinMethodName.SignMessage -> moshi.fromJson(rawParams) + .getOrElse { return it.left() } + ?.let { req -> + WcBitcoinMethod.SignMessage( + account = req.account, + message = req.message, + address = req.address, + protocol = req.protocol ?: "ecdsa", + ) + } + }.right() + } + + /** + * Bitcoin namespace converter for CAIP-2 chain IDs. + * + * Bitcoin uses BIP-122 namespace with genesis block hash as reference. + * Example: bip122:000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f + */ + internal class NamespaceConverter @Inject constructor( + override val excludedBlockchains: ExcludedBlockchains, + ) : WcNamespaceConverter { + + override val namespaceKey: NamespaceKey = NamespaceKey(NAMESPACE) + + override fun toBlockchain(chainId: CAIP2): Blockchain? { + if (chainId.namespace != namespaceKey.key) return null + return when { + isMainnetReference(chainId.reference) -> Blockchain.Bitcoin + isTestnetReference(chainId.reference) -> Blockchain.BitcoinTestnet + else -> null + } + } + + private fun isMainnetReference(reference: String): Boolean { + return MAINNET_GENESIS_PREFIX.any { reference.startsWith(it, ignoreCase = true) } || + reference.equals("mainnet", ignoreCase = true) + } + + private fun isTestnetReference(reference: String): Boolean { + return TESTNET_GENESIS_PREFIX.any { reference.startsWith(it, ignoreCase = true) } || + reference.equals("testnet", ignoreCase = true) + } + + companion object { + private const val NAMESPACE = "bip122" + + // Bitcoin mainnet genesis hash prefixes (supports any truncated version) + private val MAINNET_GENESIS_PREFIX = listOf( + "000000000019d6689c085ae165831e93", // Mainnet genesis hash prefix (min 32 chars for uniqueness) + ) + + // Bitcoin testnet genesis hash prefixes (supports any truncated version) + private val TESTNET_GENESIS_PREFIX = listOf( + "000000000933ea01ad0ee984209779ba", // Standard testnet genesis hash prefix (9 leading zeros) + "0000000000933ea01ad0ee984209779ba", // Alternative testnet prefix (10 leading zeros) + ) + } + } + + /** + * Factory classes for creating Bitcoin WalletConnect use cases. + */ + internal class Factories @Inject constructor( + val sendTransfer: WcBitcoinSendTransferUseCase.Factory, + val getAccountAddresses: WcBitcoinGetAccountAddressesUseCase.Factory, + val signPsbt: WcBitcoinSignPsbtUseCase.Factory, + val signMessage: WcBitcoinSignMessageUseCase.Factory, + ) + + companion object { + private const val NAMESPACE = "bip122" + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSendTransferUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSendTransferUseCase.kt new file mode 100644 index 0000000000..0ded51f956 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSendTransferUseCase.kt @@ -0,0 +1,146 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.left +import com.tangem.blockchain.blockchains.bitcoin.BitcoinTransactionExtras +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.extensions.Result as SdkResult +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +import com.tangem.data.walletconnect.sign.SignCollector +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate +import com.tangem.domain.core.lce.LceFlow +import com.tangem.domain.walletconnect.WcTransactionSignerProvider +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck +import com.tangem.domain.walletconnect.usecase.method.WcMutableFee +import com.tangem.domain.walletconnect.usecase.method.WcSignState +import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import java.math.BigDecimal + +/** + * Use case for Bitcoin sendTransfer WalletConnect method. + * + * Sends a Bitcoin transfer transaction with optional memo (OP_RETURN) and custom change address. + */ +@Suppress("LongParameterList") +internal class WcBitcoinSendTransferUseCase @AssistedInject constructor( + @Assisted override val context: WcMethodUseCaseContext, + @Assisted override val method: WcBitcoinMethod.SendTransfer, + private val walletManagersFacade: WalletManagersFacade, + private val signerProvider: WcTransactionSignerProvider, + override val respondService: WcRespondService, + override val analytics: AnalyticsEventHandler, + blockAidDelegate: BlockAidVerificationDelegate, +) : BaseWcSignUseCase(), + WcTransactionUseCase, + WcMutableFee { + + override val wallet get() = context.session.wallet + + private val transferAmount: Amount by lazy { + createAmountFromSatoshis(method.amount) + } + + override val securityStatus: LceFlow = + blockAidDelegate.getSecurityStatus( + network = network, + method = method, + rawSdkRequest = rawSdkRequest, + session = session, + accountAddress = context.accountAddress, + ).map { lce -> + lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } + } + + override suspend fun SignCollector.onSign(state: WcSignState) { + val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network) + ?: run { + emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left())) + return + } + + val signer = signerProvider.createSigner(wallet) + when (val result = walletManager.send(state.signModel, signer)) { + is SdkResult.Success -> { + val response = buildJsonResponse(result.data.hash) + val wcRespondResult = respondService.respond(rawSdkRequest, response) + emit(state.toResult(wcRespondResult)) + } + is SdkResult.Failure -> { + emit(state.toResult(HandleMethodError.UnknownError(result.error.customMessage).left())) + } + } + } + + override suspend fun FlowCollector.onMiddleAction( + signModel: TransactionData, + action: WcBitcoinTxAction, + ) { + val uncompiled = signModel as? TransactionData.Uncompiled ?: return + val newState = when (action) { + is WcBitcoinTxAction.UpdateFee -> uncompiled.copy(fee = action.fee) + } + emit(newState) + } + + override suspend fun dAppFee(): Fee? = null + + override fun updateFee(fee: Fee) { + middleAction(WcBitcoinTxAction.UpdateFee(fee)) + } + + override fun invoke(): Flow> = flow { + val fee = dAppFee() + val transactionData = createTransactionData(fee) + emitAll(delegate.invoke(transactionData)) + } + + private fun createTransactionData(fee: Fee?): TransactionData.Uncompiled { + return TransactionData.Uncompiled( + amount = transferAmount, + fee = fee, + sourceAddress = context.accountAddress, + destinationAddress = method.recipientAddress, + extras = BitcoinTransactionExtras( + memo = method.memo, + changeAddress = method.changeAddress, + ), + ) + } + + private fun createAmountFromSatoshis(satoshis: String): Amount { + val btcValue = BigDecimal(satoshis).divide(SATOSHI_IN_BTC) + return Amount( + currencySymbol = network.currencySymbol, + value = btcValue, + decimals = BITCOIN_DECIMALS, + ) + } + + private fun buildJsonResponse(txid: String): String = "{\"txid\":\"$txid\"}" + + @AssistedFactory + interface Factory { + fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SendTransfer): WcBitcoinSendTransferUseCase + } + + private companion object { + val SATOSHI_IN_BTC = BigDecimal("100000000") + const val BITCOIN_DECIMALS = 8 + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignMessageUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignMessageUseCase.kt new file mode 100644 index 0000000000..0f60af6c1e --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignMessageUseCase.kt @@ -0,0 +1,117 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.left +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.tangem.blockchain.extensions.Result as SdkResult +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +import com.tangem.data.walletconnect.sign.SignCollector +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.datasource.di.SdkMoshi +import com.domain.blockaid.models.transaction.CheckTransactionResult +import com.domain.blockaid.models.transaction.SimulationResult +import com.domain.blockaid.models.transaction.ValidationResult +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.core.lce.LceFlow +import com.tangem.domain.walletconnect.WcTransactionSignerProvider +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.usecase.method.WcMessageSignUseCase +import com.tangem.domain.walletconnect.usecase.method.WcSignState +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * Use case for Bitcoin signMessage WalletConnect method. + * + * Signs an arbitrary message using Bitcoin message signing format (BIP-137 ECDSA). + */ +@JsonClass(generateAdapter = true) +internal data class SignMessageResponse( + @Json(name = "address") val address: String, + @Json(name = "signature") val signature: String, + @Json(name = "messageHash") val messageHash: String? = null, +) + +@Suppress("LongParameterList") +internal class WcBitcoinSignMessageUseCase @AssistedInject constructor( + @Assisted override val context: WcMethodUseCaseContext, + @Assisted override val method: WcBitcoinMethod.SignMessage, + private val walletManagersFacade: WalletManagersFacade, + private val signerProvider: WcTransactionSignerProvider, + override val respondService: WcRespondService, + override val analytics: AnalyticsEventHandler, + @SdkMoshi private val moshi: Moshi, +) : BaseWcSignUseCase(), + WcMessageSignUseCase { + + override val wallet get() = context.session.wallet + + // BlockAid doesn't support Bitcoin message signing + override val securityStatus: LceFlow = flowOf( + Lce.Content( + CheckTransactionResult( + validation = ValidationResult.FAILED_TO_VALIDATE, + simulation = SimulationResult.FailedToSimulate, + ), + ), + ) + + override suspend fun SignCollector.onSign( + state: WcSignState, + ) { + val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network) + ?: run { + emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left())) + return + } + + val signer = signerProvider.createSigner(wallet) + + // Use the address from method, or fallback to account if not specified + val addressToSign = method.address ?: method.account + + // Use MessageSigner to sign the message + when (val result = walletManager.signMessage( + message = method.message, + address = addressToSign, + protocol = method.protocol, + signer = signer, + )) { + is SdkResult.Success -> { + val response = buildJsonResponse(result.data) + val wcRespondResult = respondService.respond(rawSdkRequest, response) + emit(state.toResult(wcRespondResult)) + } + is SdkResult.Failure -> { + emit(state.toResult(HandleMethodError.UnknownError(result.error.customMessage).left())) + } + } + } + + override fun invoke(): Flow> { + return delegate.invoke(initModel = WcMessageSignUseCase.SignModel(method.message)) + } + + private fun buildJsonResponse(data: com.tangem.blockchain.common.messagesigning.MessageSignatureResult): String { + val response = SignMessageResponse( + address = data.address, + signature = data.signature, + messageHash = data.messageHash, + ) + return moshi.adapter(SignMessageResponse::class.java).toJson(response) + } + + @AssistedFactory + interface Factory { + fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SignMessage): WcBitcoinSignMessageUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignPsbtUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignPsbtUseCase.kt new file mode 100644 index 0000000000..302376444a --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinSignPsbtUseCase.kt @@ -0,0 +1,142 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import arrow.core.left +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.SignInput +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.extensions.Result as SdkResult +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.data.walletconnect.respond.WcRespondService +import com.tangem.data.walletconnect.sign.BaseWcSignUseCase +import com.tangem.data.walletconnect.sign.SignCollector +import com.tangem.data.walletconnect.sign.SignStateConverter.toResult +import com.tangem.data.walletconnect.sign.WcMethodUseCaseContext +import com.tangem.data.walletconnect.utils.BlockAidVerificationDelegate +import com.tangem.datasource.di.SdkMoshi +import com.tangem.domain.core.lce.LceFlow +import com.tangem.domain.walletconnect.WcTransactionSignerProvider +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.model.WcBitcoinMethod +import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck +import com.tangem.domain.walletconnect.usecase.method.WcSignState +import com.tangem.domain.walletconnect.usecase.method.WcTransactionUseCase +import com.tangem.domain.walletmanager.WalletManagersFacade +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Use case for Bitcoin signPsbt WalletConnect method. + * + * Signs a Partially Signed Bitcoin Transaction (BIP-174 PSBT) with optional broadcast. + */ +@JsonClass(generateAdapter = true) +internal data class SignPsbtResponse( + @Json(name = "psbt") val psbt: String, + @Json(name = "txid") val txid: String? = null, +) + +@Suppress("LongParameterList") +internal class WcBitcoinSignPsbtUseCase @AssistedInject constructor( + @Assisted override val context: WcMethodUseCaseContext, + @Assisted override val method: WcBitcoinMethod.SignPsbt, + private val walletManagersFacade: WalletManagersFacade, + private val signerProvider: WcTransactionSignerProvider, + override val respondService: WcRespondService, + override val analytics: AnalyticsEventHandler, + blockAidDelegate: BlockAidVerificationDelegate, + @SdkMoshi private val moshi: Moshi, +) : BaseWcSignUseCase(), + WcTransactionUseCase { + + override val wallet get() = context.session.wallet + + override val securityStatus: LceFlow = + blockAidDelegate.getSecurityStatus( + network = network, + method = method, + rawSdkRequest = rawSdkRequest, + session = session, + accountAddress = context.accountAddress, + ).map { lce -> + lce.map { result -> BlockAidTransactionCheck.Result.Plain(result) } + } + + override suspend fun SignCollector.onSign(state: WcSignState) { + // Update wallet manager to refresh UTXO data before processing Bitcoin transaction + walletManagersFacade.update( + userWalletId = wallet.walletId, + network = network, + extraTokens = emptySet(), + ) + + val walletManager = walletManagersFacade.getOrCreateWalletManager(wallet.walletId, network) + ?: run { + emit(state.toResult(HandleMethodError.UnknownError("Failed to create wallet manager").left())) + return + } + + val signer = signerProvider.createSigner(wallet) + val signInputs = method.signInputs.map { input -> + SignInput( + address = input.address, + index = input.index, + sighashTypes = input.sighashTypes, + ) + } + val signedPsbtResult = walletManager.signPsbt( + psbtBase64 = method.psbt, + signInputs = signInputs, + signer = signer, + ) + + when (signedPsbtResult) { + is SdkResult.Success -> { + val signedPsbt = signedPsbtResult.data + val txid = if (method.shouldBroadcast) { + when (val broadcastResult = walletManager.broadcastPsbt(signedPsbt)) { + is SdkResult.Success -> broadcastResult.data + is SdkResult.Failure -> { + val error = HandleMethodError.UnknownError(broadcastResult.error.customMessage).left() + emit(state.toResult(error)) + return + } + } + } else { + null + } + + val response = buildJsonResponse(signedPsbt, txid) + val wcRespondResult = respondService.respond(rawSdkRequest, response) + emit(state.toResult(wcRespondResult)) + } + is SdkResult.Failure -> { + emit(state.toResult(HandleMethodError.UnknownError(signedPsbtResult.error.customMessage).left())) + } + } + } + + override fun invoke(): Flow> { + val transactionData = TransactionData.Compiled( + value = TransactionData.Compiled.Data.RawString(method.psbt), + ) + return delegate.invoke(transactionData) + } + + private fun buildJsonResponse(signedPsbt: String, txid: String?): String { + val response = SignPsbtResponse( + psbt = signedPsbt, + txid = txid, + ) + return moshi.adapter(SignPsbtResponse::class.java).toJson(response) + } + + @AssistedFactory + interface Factory { + fun create(context: WcMethodUseCaseContext, method: WcBitcoinMethod.SignPsbt): WcBitcoinSignPsbtUseCase + } +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinTxAction.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinTxAction.kt new file mode 100644 index 0000000000..cb00b43745 --- /dev/null +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/bitcoin/WcBitcoinTxAction.kt @@ -0,0 +1,8 @@ +package com.tangem.data.walletconnect.network.bitcoin + +import com.tangem.blockchain.common.transaction.Fee + +sealed interface WcBitcoinTxAction { + + data class UpdateFee(val fee: Fee) : WcBitcoinTxAction +} \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/SolanaBlockAidAddressConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/SolanaBlockAidAddressConverter.kt index 6347e2f390..1a09c57487 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/SolanaBlockAidAddressConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/solana/SolanaBlockAidAddressConverter.kt @@ -2,7 +2,7 @@ package com.tangem.data.walletconnect.network.solana import com.tangem.blockchain.extensions.decodeBase58 import com.tangem.blockchain.extensions.encodeBase64NoWrap -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.utils.converter.Converter import com.tangem.utils.logging.TangemLogger import javax.inject.Inject diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt index 02ce4d738f..c7e868cf94 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/DefaultWcPairUseCase.kt @@ -8,7 +8,7 @@ import com.domain.blockaid.models.dapp.CheckDAppResult import com.domain.blockaid.models.dapp.DAppData import com.reown.walletkit.client.Wallet import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.getDappOriginUrl import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.walletconnect.WcAnalyticEvents diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt index a747091d4a..d5aa2c0090 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/pair/WcPairSdkDelegate.kt @@ -5,7 +5,7 @@ import arrow.core.left import arrow.core.right import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.getDappOriginUrl import com.tangem.datasource.local.walletconnect.WalletConnectStore diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt index 130530c326..80d677fed7 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestService.kt @@ -1,8 +1,9 @@ package com.tangem.data.walletconnect.request import com.reown.walletkit.client.Wallet +import com.tangem.data.walletconnect.BuildConfig import com.tangem.data.walletconnect.respond.DefaultWcRespondService -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionRequestConverter import com.tangem.data.walletconnect.utils.getDappOriginUrl @@ -43,6 +44,7 @@ internal class DefaultWcRequestService( respondService.rejectRequestNonBlock(sr) if (name.raw.startsWith("wallet_")) return } + _wcRequest.trySend(name to sr) } @@ -62,6 +64,11 @@ internal class DefaultWcRequestService( } private fun saveRequest(request: WcSdkSessionRequest) { + // Skip caching in debug builds since filtering is disabled + if (BuildConfig.DEBUG) { + return + } + val hash = respondService.sessionRequestHash(request) val now = DateTime.now().millis respondService.cachedRequest.update { it + (now to hash) } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestUseCaseFactory.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestUseCaseFactory.kt index 41caf2afe1..7352709bd1 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestUseCaseFactory.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/request/DefaultWcRequestUseCaseFactory.kt @@ -4,7 +4,7 @@ import arrow.core.Either import arrow.core.left import arrow.core.right import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcNamespaceConverter import com.tangem.domain.walletconnect.WcAnalyticEvents import com.tangem.domain.walletconnect.WcRequestUseCaseFactory diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt index fdb3bf7bb1..ea4645374e 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/respond/DefaultWcRespondService.kt @@ -7,7 +7,7 @@ import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.common.extensions.calculateSha256 import com.tangem.common.extensions.toHexString -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.domain.walletconnect.model.WcRequestError import com.tangem.domain.walletconnect.model.sdkcopy.WcSdkSessionRequest import com.tangem.utils.logging.TangemLogger diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt index c6fed93088..7d03acdda4 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sessions/DefaultWcSessionsManager.kt @@ -6,7 +6,7 @@ import arrow.core.right import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.data.walletconnect.utils.WC_TAG +import com.tangem.domain.walletconnect.WC_TAG import com.tangem.data.walletconnect.utils.WcNetworksConverter import com.tangem.data.walletconnect.utils.WcSdkObserver import com.tangem.data.walletconnect.utils.WcSdkSessionConverter diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt index 7f934668f6..410b86fca9 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/sign/BlockAidChainNameConverter.kt @@ -29,6 +29,9 @@ internal object BlockAidChainNameConverter : Converter { Blockchain.Solana -> "mainnet" + Blockchain.Bitcoin -> "bitcoin" + Blockchain.BitcoinTestnet -> "bitcoin-testnet" + else -> null } } diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/BlockAidVerificationDelegate.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/BlockAidVerificationDelegate.kt index f0d71c240c..2f3ddc858d 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/BlockAidVerificationDelegate.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/BlockAidVerificationDelegate.kt @@ -6,6 +6,7 @@ import com.tangem.domain.blockaid.BlockAidVerifier import com.tangem.domain.core.lce.Lce import com.tangem.domain.core.lce.LceFlow import com.tangem.domain.models.network.Network +import com.tangem.domain.walletconnect.model.WcBitcoinMethod import com.tangem.domain.walletconnect.model.WcEthMethod import com.tangem.domain.walletconnect.model.WcMethod import com.tangem.domain.walletconnect.model.WcSession @@ -26,10 +27,8 @@ internal class BlockAidVerificationDelegate @Inject constructor( session: WcSession, accountAddress: String?, ): LceFlow = flow { - val failedResult = CheckTransactionResult( - validation = ValidationResult.FAILED_TO_VALIDATE, - simulation = SimulationResult.FailedToSimulate, - ) + val failedResult = createFailedResult() + if (accountAddress.isNullOrEmpty()) { emit(Lce.Content(failedResult)) return@flow @@ -43,18 +42,22 @@ internal class BlockAidVerificationDelegate @Inject constructor( val methodName = when (method) { is WcEthMethod -> rawSdkRequest.request.method is WcSolanaMethod -> method.trimmedPrefixMethodName + is WcBitcoinMethod -> rawSdkRequest.request.method is WcMethod.Unsupported -> { emit(Lce.Content(failedResult)) return@flow } } + val params = when (method) { is WcEthMethod -> TransactionParams.Evm(rawSdkRequest.request.params) is WcSolanaMethod.SignAllTransaction -> TransactionParams.Solana(method.transaction) is WcSolanaMethod.SignTransaction -> TransactionParams.Solana(listOf(method.transaction)) - is WcSolanaMethod.SignMessage -> { - // BlockAid doesn't support solana_signMessage - emit(Lce.Content(failedResult)) + is WcSolanaMethod.SignMessage, + is WcBitcoinMethod, + -> { + // BlockAid doesn't support Solana message signing and Bitcoin methods + emit(Lce.Content(createSafeResult())) return@flow } else -> { @@ -81,4 +84,14 @@ internal class BlockAidVerificationDelegate @Inject constructor( }, ) } + + private fun createFailedResult() = CheckTransactionResult( + validation = ValidationResult.FAILED_TO_VALIDATE, + simulation = SimulationResult.FailedToSimulate, + ) + + private fun createSafeResult() = CheckTransactionResult( + validation = ValidationResult.SAFE, + simulation = SimulationResult.FailedToSimulate, + ) } \ No newline at end of file diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt index 06637ecefd..4c70570c4d 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/utils/WcSdkObserver.kt @@ -3,8 +3,6 @@ package com.tangem.data.walletconnect.utils import com.reown.walletkit.client.Wallet import com.reown.walletkit.client.WalletKit -const val WC_TAG = "Wallet Connect" - internal interface WcSdkObserver : WalletKit.WalletDelegate { override val onSessionAuthenticate: ((Wallet.Model.SessionAuthenticate, Wallet.Model.VerifyContext) -> Unit)? diff --git a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt index 01fb3a9321..5895d2b738 100644 --- a/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt +++ b/domain/blockaid/models/src/main/kotlin/com/domain/blockaid/models/transaction/TransactionData.kt @@ -33,4 +33,13 @@ sealed class TransactionParams { data class Solana( val transactions: List, ) : TransactionParams() + + /** + * Parameters for Bitcoin transactions + * + * @property params JSON-encoded transaction parameters + */ + data class Bitcoin( + val params: String, + ) : TransactionParams() } \ No newline at end of file diff --git a/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcBitcoinMethod.kt b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcBitcoinMethod.kt new file mode 100644 index 0000000000..f989194c01 --- /dev/null +++ b/domain/wallet-connect/models/src/main/java/com/tangem/domain/walletconnect/model/WcBitcoinMethod.kt @@ -0,0 +1,93 @@ +package com.tangem.domain.walletconnect.model + +/** + * Bitcoin WalletConnect method names. + * + * @see Bitcoin RPC Reference + */ +enum class WcBitcoinMethodName(override val raw: String) : WcMethodName { + SendTransfer("sendTransfer"), + GetAccountAddresses("getAccountAddresses"), + SignPsbt("signPsbt"), + SignMessage("signMessage"), +} + +/** + * Bitcoin WalletConnect methods. + */ +sealed interface WcBitcoinMethod : WcMethod { + val methodName: String + + /** + * Send a Bitcoin transfer transaction. + * + * @property account Source address (SegWit) + * @property recipientAddress Destination address + * @property amount Amount in satoshis + * @property memo Optional OP_RETURN memo + * @property changeAddress Optional custom change address + */ + data class SendTransfer( + val account: String, + val recipientAddress: String, + val amount: String, + val memo: String?, + val changeAddress: String?, + ) : WcBitcoinMethod { + override val methodName: String = WcBitcoinMethodName.SendTransfer.raw + } + + /** + * Get account addresses filtered by intention. + * + * @property account Connected account address + * @property intentions Optional filter ("payment", "ordinal") + */ + data class GetAccountAddresses( + val account: String, + val intentions: List?, + ) : WcBitcoinMethod { + override val methodName: String = WcBitcoinMethodName.GetAccountAddresses.raw + } + + /** + * Sign a PSBT (BIP-174). + * + * @property psbt PSBT in Base64 encoding + * @property signInputs List of inputs to sign + * @property shouldBroadcast Whether to broadcast after signing + */ + data class SignPsbt( + val psbt: String, + val signInputs: List, + val shouldBroadcast: Boolean, + ) : WcBitcoinMethod { + override val methodName: String = WcBitcoinMethodName.SignPsbt.raw + } + + /** + * Sign input specification for PSBT. + */ + data class SignInput( + val address: String, + val index: Int, + val sighashTypes: List?, + ) + + /** + * Sign an arbitrary message using Bitcoin message signing format. + * + * @property account Connected account address + * @property message Message to sign + * @property address Optional specific address to sign with + * @property protocol Signing protocol ("ecdsa" or "bip322") + */ + data class SignMessage( + val account: String, + val message: String, + val address: String?, + val protocol: String, + ) : WcBitcoinMethod { + override val methodName: String = WcBitcoinMethodName.SignMessage.raw + } +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcLogTag.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcLogTag.kt new file mode 100644 index 0000000000..5fb6a63542 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcLogTag.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.walletconnect + +/** + * Common log tag for all WalletConnect-related logging across modules. + */ +const val WC_TAG = "WalletConnect" \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcTransactionSignerProvider.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcTransactionSignerProvider.kt new file mode 100644 index 0000000000..7cecd888b9 --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcTransactionSignerProvider.kt @@ -0,0 +1,21 @@ +package com.tangem.domain.walletconnect + +import com.tangem.blockchain.common.TransactionSigner +import com.tangem.domain.models.wallet.UserWallet + +/** + * Provider for creating transaction signers for WalletConnect operations. + * + * This interface abstracts the creation of [TransactionSigner] instances + * to avoid direct dependency on card SDK configuration in wallet-connect module. + */ +interface WcTransactionSignerProvider { + + /** + * Creates a transaction signer for the given wallet. + * + * @param wallet The user wallet to create a signer for + * @return A [TransactionSigner] instance for the wallet + */ + fun createSigner(wallet: UserWallet): TransactionSigner +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/featuretoggle/WalletConnectFeatureToggles.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/featuretoggle/WalletConnectFeatureToggles.kt new file mode 100644 index 0000000000..a48906360d --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/featuretoggle/WalletConnectFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.domain.walletconnect.featuretoggle + +interface WalletConnectFeatureToggles { + val isBitcoinEnabled: Boolean +} \ No newline at end of file diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcGetAddressesUseCase.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcGetAddressesUseCase.kt new file mode 100644 index 0000000000..5cdbd444fd --- /dev/null +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/usecase/method/WcGetAddressesUseCase.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.walletconnect.usecase.method + +import arrow.core.Either +import com.tangem.domain.walletconnect.model.HandleMethodError + +/** + * Base use case for WalletConnect methods that return wallet addresses. + * + * This is a non-signing operation that returns addresses immediately. + */ +interface WcGetAddressesUseCase : WcMethodUseCase, WcMethodContext { + + /** + * Get wallet addresses. + * + * @return Either error or list of addresses with their metadata + */ + suspend operator fun invoke(): Either + + /** + * Reject the request. + */ + fun reject() + + /** + * Result containing wallet addresses. + */ + data class GetAddressesResult( + val addresses: List, + ) + + /** + * Address information. + */ + data class AddressInfo( + val address: String, + val publicKey: String?, + val path: String?, + val intention: String?, + ) +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt index 7a3bbca637..5b44bc3973 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt @@ -21,6 +21,7 @@ import com.tangem.features.walletconnect.components.WcRoutingComponent import com.tangem.features.walletconnect.connections.components.AlertsComponent import com.tangem.features.walletconnect.connections.components.AlertsComponent.AlertType.* import com.tangem.features.walletconnect.connections.components.WcPairComponent +import com.tangem.features.walletconnect.transaction.components.addresses.WcGetAddressesComponent import com.tangem.features.walletconnect.transaction.components.chain.WcAddNetworkContainerComponent import com.tangem.features.walletconnect.transaction.components.chain.WcSwitchNetworkComponent import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams @@ -79,6 +80,10 @@ internal class DefaultWcRoutingComponent @AssistedInject constructor( appComponentContext = childContext, params = WcTransactionModelParams(config.rawRequest), ) + is WcInnerRoute.GetAddresses -> WcGetAddressesComponent( + appComponentContext = childContext, + params = WcTransactionModelParams(config.rawRequest), + ) is WcInnerRoute.Send -> WcSendTransactionContainerComponent( appComponentContext = childContext, params = WcTransactionModelParams(config.rawRequest), diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcInnerRoute.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcInnerRoute.kt index bc9f386e88..637069b54e 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcInnerRoute.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcInnerRoute.kt @@ -25,6 +25,9 @@ internal sealed interface WcInnerRoute : Route { @Serializable data class SwitchNetwork(override val rawRequest: WcSdkSessionRequest) : Method + @Serializable + data class GetAddresses(override val rawRequest: WcSdkSessionRequest) : Method + @Serializable data class Pair(val request: WcPairRequest) : WcInnerRoute diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt index 341ab307f2..ddfb9f8b3b 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/WcRoutingModel.kt @@ -7,11 +7,13 @@ import com.tangem.core.decompose.model.Model import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.WcRequestService +import com.tangem.domain.walletconnect.model.WcBitcoinMethodName import com.tangem.domain.walletconnect.model.WcEthMethodName import com.tangem.domain.walletconnect.model.WcMethodName import com.tangem.domain.walletconnect.model.WcSolanaMethodName import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* +import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @ModelScoped @@ -32,6 +34,7 @@ internal class WcRoutingModel @Inject constructor( } fun onSlotEmpty() { + TangemLogger.d("WC Queue: onSlotEmpty() called") isSlotEmpty.update { true } } @@ -44,18 +47,35 @@ internal class WcRoutingModel @Inject constructor( WcEthMethodName.SignTypeData, WcEthMethodName.SignTypeDataV4, WcSolanaMethodName.SignMessage, - -> WcInnerRoute.SignMessage(rawRequest) + WcBitcoinMethodName.SignMessage, + -> { + WcInnerRoute.SignMessage(rawRequest) + } WcEthMethodName.AddEthereumChain, - -> WcInnerRoute.AddNetwork(rawRequest) + -> { + WcInnerRoute.AddNetwork(rawRequest) + } WcEthMethodName.SwitchEthereumChain, - -> WcInnerRoute.SwitchNetwork(rawRequest) + -> { + WcInnerRoute.SwitchNetwork(rawRequest) + } WcEthMethodName.SignTransaction, WcEthMethodName.SendTransaction, WcSolanaMethodName.SignTransaction, WcSolanaMethodName.SendAllTransaction, - -> WcInnerRoute.Send(rawRequest) + WcBitcoinMethodName.SendTransfer, + WcBitcoinMethodName.SignPsbt, + -> { + WcInnerRoute.Send(rawRequest) + } + WcBitcoinMethodName.GetAccountAddresses, + -> { + WcInnerRoute.GetAddresses(rawRequest) + } is WcMethodName.Unsupported, - -> WcInnerRoute.UnsupportedMethodAlert + -> { + WcInnerRoute.UnsupportedMethodAlert + } } } @@ -64,7 +84,9 @@ internal class WcRoutingModel @Inject constructor( merge(requestFlow, pairFlow) .onEach { configuration -> + TangemLogger.d("WC Queue: Received configuration $configuration, waiting for queue ready") awaitQueueReady() + TangemLogger.d("WC Queue: Queue ready, pushing configuration") isSlotEmpty.update { false } innerRouter.push(configuration) } @@ -76,7 +98,12 @@ internal class WcRoutingModel @Inject constructor( permittedAppRoute, cardSdkProvider.sdk.uiVisibility(), ) { isSlotEmpty, permittedAppRoute, isCardSdkVisible -> - isSlotEmpty && permittedAppRoute && !isCardSdkVisible + val isReady = isSlotEmpty && permittedAppRoute && !isCardSdkVisible + TangemLogger.d( + "WC Queue: isSlotEmpty=$isSlotEmpty, permittedAppRoute=$permittedAppRoute, " + + "isCardSdkVisible=$isCardSdkVisible, ready=$isReady", + ) + isReady }.first { it } fun onAppRouteChange(appRoute: AppRoute) { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt index 620599339d..66f4e4fb8d 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/di/WalletConnectModelModule.kt @@ -5,6 +5,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.features.walletconnect.connections.model.* import com.tangem.features.walletconnect.connections.routing.WcRoutingModel import com.tangem.features.walletconnect.transaction.model.WcAddNetworkModel +import com.tangem.features.walletconnect.transaction.model.WcGetAddressesModel import com.tangem.features.walletconnect.transaction.model.WcSendTransactionModel import com.tangem.features.walletconnect.transaction.model.WcSignTransactionModel import com.tangem.features.walletconnect.transaction.model.WcSwitchNetworkModel @@ -53,6 +54,11 @@ internal interface WalletConnectModelModule { @ClassKey(WcAddNetworkModel::class) fun bindWcAddNetworkModel(model: WcAddNetworkModel): Model + @Binds + @IntoMap + @ClassKey(WcGetAddressesModel::class) + fun bindWcGetAddressesModel(model: WcGetAddressesModel): Model + @Binds @IntoMap @ClassKey(WcSwitchNetworkModel::class) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/addresses/WcGetAddressesComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/addresses/WcGetAddressesComponent.kt new file mode 100644 index 0000000000..b57c6484cc --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/addresses/WcGetAddressesComponent.kt @@ -0,0 +1,25 @@ +package com.tangem.features.walletconnect.transaction.components.addresses + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams +import com.tangem.features.walletconnect.transaction.model.WcGetAddressesModel + +/** + * Component for Bitcoin getAccountAddresses WalletConnect method. + */ +internal class WcGetAddressesComponent( + appComponentContext: AppComponentContext, + params: WcTransactionModelParams, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + @Suppress("UnusedPrivateProperty") + private val model: WcGetAddressesModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/TransactionParamsConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/TransactionParamsConverter.kt index bc884a209f..d82c809cf4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/TransactionParamsConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/TransactionParamsConverter.kt @@ -4,6 +4,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestBlockUM import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionRequestInfoItemUM import com.tangem.utils.converter.Converter +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.toImmutableList import org.json.JSONArray import org.json.JSONObject @@ -58,7 +59,16 @@ internal class TransactionParamsConverter @Inject constructor() : Converter loop(JSONArray(value)) + '{' -> loop(JSONObject(value)) + else -> loop(JSONArray(value)) // default to array for backward compatibility + } + } catch (e: Exception) { + TangemLogger.withTag("Wallet Connect").e("Failed to parse transaction params: ${e.message.orEmpty()}") + } + return result } diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt index e4969a134c..f1b56b37bc 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt @@ -2,6 +2,7 @@ package com.tangem.features.walletconnect.transaction.converter import com.tangem.common.ui.account.AccountTitleUM import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.walletconnect.model.WcBitcoinMethod import com.tangem.domain.walletconnect.model.WcEthMethod import com.tangem.domain.walletconnect.model.WcSolanaMethod import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck @@ -39,6 +40,9 @@ internal class WcSendTransactionUMConverter @Inject constructor( is WcEthMethod.SignTransaction, is WcSolanaMethod.SignAllTransaction, is WcSolanaMethod.SignTransaction, + is WcBitcoinMethod.SendTransfer, + is WcBitcoinMethod.SignPsbt, + is WcBitcoinMethod.SignMessage, -> WcSendTransactionUM( transaction = WcSendTransactionItemUM( onDismiss = value.actions.onDismiss, diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt new file mode 100644 index 0000000000..05b5667a53 --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/addresses/WcGetAddressesUM.kt @@ -0,0 +1,23 @@ +package com.tangem.features.walletconnect.transaction.entity.addresses + +import androidx.annotation.DrawableRes +import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM +import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionAppInfoContentUM + +/** + * UI model for Bitcoin getAccountAddresses WalletConnect request. + */ +internal data class WcGetAddressesUM( + val appInfo: WcTransactionAppInfoContentUM, + val networkInfo: WcNetworkInfoUM, + val addresses: List, + val isLoading: Boolean, + @DrawableRes val walletInteractionIcon: Int, + val onApprove: () -> Unit, + val onReject: () -> Unit, +) { + data class AddressInfo( + val address: String, + val intention: String?, + ) +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcGetAddressesModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcGetAddressesModel.kt new file mode 100644 index 0000000000..11efd9327d --- /dev/null +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcGetAddressesModel.kt @@ -0,0 +1,46 @@ +package com.tangem.features.walletconnect.transaction.model + +import androidx.compose.runtime.Stable +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.domain.walletconnect.WcRequestUseCaseFactory +import com.tangem.domain.walletconnect.model.HandleMethodError +import com.tangem.domain.walletconnect.usecase.method.WcGetAddressesUseCase +import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams +import com.tangem.features.walletconnect.transaction.converter.WcHandleMethodErrorConverter +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Model for Bitcoin getAccountAddresses WalletConnect method. + */ +@Stable +@ModelScoped +internal class WcGetAddressesModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val useCaseFactory: WcRequestUseCaseFactory, +) : Model() { + + private val params = paramsContainer.require() + + init { + modelScope.launch { + val useCase = useCaseFactory.createUseCase(params.rawRequest) + .onLeft { showErrorDialog(it) } + .getOrNull() ?: return@launch + + useCase.invoke() + .onLeft { showErrorDialog(it) } + .onRight { router.pop() } + } + } + + private fun showErrorDialog(error: HandleMethodError) { + router.push(WcHandleMethodErrorConverter.convert(error)) + } +} \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt index 2f36707a60..16074b661c 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSignTransactionModel.kt @@ -35,6 +35,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import com.tangem.utils.logging.TangemLogger +import com.tangem.domain.walletconnect.WC_TAG import javax.inject.Inject import kotlin.properties.Delegates @@ -67,14 +69,36 @@ internal class WcSignTransactionModel @Inject constructor( init { modelScope.launch { + TangemLogger.withTag(WC_TAG).i("Creating use case...") useCase = useCaseFactory.createUseCase(params.rawRequest) - .onLeft { router.push(WcHandleMethodErrorConverter.convert(it)) } - .getOrNull() ?: return@launch + .onLeft { error -> + TangemLogger.withTag(WC_TAG).e("Failed to create use case: $error") + router.push(WcHandleMethodErrorConverter.convert(error)) + } + .getOrNull() ?: run { + TangemLogger.withTag(WC_TAG).e("Use case is null, exiting") + return@launch + } + + TangemLogger.withTag(WC_TAG).i("Use case created successfully") + TangemLogger.withTag(WC_TAG).i("Use case type: ${useCase.javaClass.simpleName}") + TangemLogger.withTag(WC_TAG).i("Method: ${useCase.method}") + sendSignatureReceivedAnalytics(useCase) + + TangemLogger.withTag(WC_TAG).i("Invoking use case...") useCase.invoke() .onEach { signState -> - if (signingIsDone(signState)) return@onEach + TangemLogger.withTag(WC_TAG).i("Sign state received: ${signState.javaClass.simpleName}") + + if (signingIsDone(signState)) { + TangemLogger.withTag(WC_TAG).i("Signing is DONE, not updating UI") + return@onEach + } + + TangemLogger.withTag(WC_TAG).i("Converting to UI state...") val signTransactionUM = convertToUI(useCase, signState) + TangemLogger.withTag(WC_TAG).i("UI state created, emitting...") _uiState.emit(signTransactionUM) } .launchIn(this) @@ -101,7 +125,10 @@ internal class WcSignTransactionModel @Inject constructor( portfolioName = portfolioNameDelegate.createAccountTitleUM(useCase.session), ), ) - is WcEthMethod.MessageSign, is WcSolanaMethod.SignMessage -> signTransactionUMConverter.convert( + is WcEthMethod.MessageSign, + is WcSolanaMethod.SignMessage, + is com.tangem.domain.walletconnect.model.WcBitcoinMethod.SignMessage, + -> signTransactionUMConverter.convert( WcSignTransactionUMConverter.Input( context = useCase, signState = signState, @@ -110,7 +137,10 @@ internal class WcSignTransactionModel @Inject constructor( portfolioName = portfolioNameDelegate.createAccountTitleUM(useCase.session), ), ) - else -> null + else -> { + TangemLogger.withTag(WC_TAG).e("UNSUPPORTED METHOD: ${useCase.method.javaClass.simpleName}") + null + } } } From 778b255d2d694d7de2b413c27bd44e431311c773 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 12:37:49 +0400 Subject: [PATCH 062/206] Updated on 2026-08-14 --- .../swap/choosetoken/api/ChooseTokenBridge.kt | 5 +- .../impl/DefaultChooseTokenBridge.kt | 3 +- .../converter/ChooseTokenListItemConverter.kt | 116 ++++++++++++++---- .../impl/model/PortfolioFullBlockDelegate.kt | 19 +-- .../impl/model/PortfolioListBlockDelegate.kt | 12 +- 5 files changed, 120 insertions(+), 35 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt index ea5546654e..41c4423225 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt @@ -27,22 +27,25 @@ interface ChooseTokenBridge : ChooseTokenBridgeLegacy, ChooseTokenBridgeInternal /** * for some Feature specific tokens filtering */ - val tokenFilter: MutableStateFlow<(AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean> + val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> fun selectWalletTab(walletId: UserWalletId) data class Settings( val title: TextReference, val isShowMarketBlock: Boolean, + val isShowPaymentAccount: Boolean, ) { companion object { val SwapFrom = Settings( title = resourceReference(R.string.swapping_from_title), isShowMarketBlock = false, + isShowPaymentAccount = true, ) val SwapTo = Settings( title = resourceReference(R.string.swapping_to_title), isShowMarketBlock = true, + isShowPaymentAccount = true, ) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt index c4074529a6..39dfd2fdc1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt @@ -44,6 +44,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create( modelScope = modelScope, searchQueryState = searchQueryState, + featureSettings = settings, ) private val portfolioFullBlockDelegate: PortfolioFullBlockDelegate = portfolioFullBlockDelegateFactory.create( @@ -52,7 +53,7 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( portfolioListBlockDelegate = portfolioListBlockDelegate, ) - override val tokenFilter: MutableStateFlow<(AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean> + override val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> get() = portfolioListBlockDelegate.tokenFilter override val fullPortfolioBlock: StateFlow diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index 774df42256..296375932e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -2,19 +2,25 @@ package com.tangem.feature.swap.choosetoken.impl.converter import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter import com.tangem.common.ui.account.TokensListPortfolioItemConverter +import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenConverterParams import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems import com.tangem.common.ui.tokens.TokenItemStateConverter +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.icons.IconTint +import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM +import com.tangem.core.ui.extensions.pluralReference +import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery @@ -29,7 +35,8 @@ internal class ChooseTokenListItemConverter( private val params: TokenConverterParams, private val clickIntents: ClickIntents, private val searchQuery: SearchQuery, - private val tokenFilter: (AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean, + private val tokenFilter: (AccountStatus, CryptoCurrencyStatus) -> Boolean, + private val isShowPaymentAccount: Boolean, ) { private val isSearchingState: Boolean get() = searchQuery.isSearchingState @@ -39,6 +46,24 @@ internal class ChooseTokenListItemConverter( clickIntents.onTokenItemClick(account, currencyStatus) } + private val onAccountItemClick: (Account, isExpanded: Boolean) -> Unit = { clickedAccount, isExpanded -> + if (isExpanded) { + clickIntents.onAccountCollapseClick(clickedAccount) + } else { + clickIntents.onAccountExpandClick(clickedAccount) + } + } + + private val fiatAmountStateProvider: ((TotalFiatBalance, isExpanded: Boolean) -> FiatAmountState?) = + { totalBalance, isExpanded -> + when { + isSearchingState -> FiatAmountState.Empty + !isExpanded -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative) + else -> AccountCryptoPortfolioItemStateConverter + .createFiatAmountState(totalBalance, appCurrency) + } + } + private fun tokenStatusConverter(account: AccountStatus) = TokenItemStateConverter( appCurrency = appCurrency, onItemClick = { _, status -> onTokenClick(account, status) }, @@ -58,8 +83,12 @@ internal class ChooseTokenListItemConverter( private fun convertAccountList(params: TokenConverterParams.Account): TokenListUMData { val accountList = params.accountList val accountItems = accountList.accountStatuses - .filterCryptoPortfolio() - .map { accountStatus -> accountStatus.toPortfolioItem(params) } + .mapNotNull { accountStatus -> + when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.toPortfolioItem(params) + is AccountStatus.Payment -> accountStatus.createPaymentAccountItem(params.expandedAccounts) + } + } .filter { portfolio -> portfolio.tokens.isNotEmpty() } if (accountItems.isEmpty()) { return TokenListUMData.EmptyList @@ -77,19 +106,7 @@ internal class ChooseTokenListItemConverter( val account: Account.CryptoPortfolio = this.account val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId) val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount -> - if (isExpanded) { - clickIntents.onAccountCollapseClick(clickedAccount) - } else { - clickIntents.onAccountExpandClick(clickedAccount) - } - } - val fiatAmountStateProvider: ((TotalFiatBalance) -> FiatAmountState?) = { totalBalance -> - when { - isSearchingState -> FiatAmountState.Empty - !isExpanded -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative) - else -> AccountCryptoPortfolioItemStateConverter - .createFiatAmountState(totalBalance, appCurrency) - } + onAccountItemClick(clickedAccount, isExpanded) } val converter = AccountCryptoPortfolioItemStateConverter( @@ -97,7 +114,7 @@ internal class ChooseTokenListItemConverter( account = account, onItemClick = onItemClick.takeIf { !isSearchingState }, priceChangeLce = this.priceChangeLce, - fiatAmountStateProvider = fiatAmountStateProvider, + fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) }, subtitle2StateProvider = { _ -> null }, ) val accountItem = converter.convert(tokenList.totalFiatBalance) @@ -130,21 +147,20 @@ internal class ChooseTokenListItemConverter( } } - private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList { - fun List.filterCurrencies(): List = filter { currency -> - currency.filterByQuery() && tokenFilter(account, currency) - } + private fun List.filterCurrencies(account: AccountStatus): List = + filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) } + private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList { return when (tokenList) { TokenList.Empty -> TokenList.Empty is TokenList.Ungrouped -> { - val filtered = tokenList.currencies.filterCurrencies() + val filtered = tokenList.currencies.filterCurrencies(account) if (filtered.isEmpty()) TokenList.Empty else tokenList.copy(currencies = filtered) } is TokenList.GroupedByNetwork -> { val filteredGroups = tokenList.groups .map { group -> - val filteredCurrencies = group.currencies.filterCurrencies() + val filteredCurrencies = group.currencies.filterCurrencies(account) group.copy(currencies = filteredCurrencies) } .filter { group -> group.currencies.isNotEmpty() } @@ -159,4 +175,56 @@ internal class ChooseTokenListItemConverter( currency.symbol.contains(searchQuery.value, ignoreCase = true) return isSearchFilter } + + private fun AccountStatus.Payment.createPaymentAccountItem( + expandedAccounts: Set, + ): TokensListItemUM.Portfolio? { + if (!isShowPaymentAccount) return null + val paymentCurrency: CryptoCurrencyStatus = when (val status = this.value) { + is PaymentAccountStatusValue.Error, + is PaymentAccountStatusValue.IssuingCard, + PaymentAccountStatusValue.NotCreated, + is PaymentAccountStatusValue.UnderReview, + PaymentAccountStatusValue.Loading, + -> return null + is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus + is PaymentAccountStatusValue.Locked -> status.cryptoCurrencyStatus + } + val account = this.account + val tokensCount = 1 + val isExpanded = isSearchingState || expandedAccounts.contains(account.accountId) + val onItemClick: (TokenItemState) -> Unit = { + onAccountItemClick(account, isExpanded) + } + val fiatBalance: TotalFiatBalance = this.value.totalFiatBalance + val fiatAmountState = fiatAmountStateProvider(fiatBalance, isExpanded) + val paymentAccountItem = TokenItemState.Content( + id = account.accountId.value, + iconState = CurrencyIconState.PaymentAccount(), + titleState = TokenItemState.TitleState.Content(text = account.accountName.toUM().value), + subtitleState = TokenItemState.SubtitleState.TextContent( + value = pluralReference( + R.plurals.common_tokens_count, + count = tokensCount, + formatArgs = wrappedList(tokensCount), + ), + isAvailable = false, + ), + onItemClick = onItemClick.takeIf { !isSearchingState }, + fiatAmountState = fiatAmountState, + subtitle2State = null, + onItemLongClick = null, + ) + val tokenConverter = tokenStatusConverter(this) + val filtered = listOf(paymentCurrency) + .filterCurrencies(this) + .map { currency -> TokensListItemUM.Token(tokenConverter.convert(currency)) } + + return TokensListPortfolioItemConverter( + tokenItemUM = paymentAccountItem, + isExpanded = isExpanded, + isCollapsable = !isSearchingState, + tokens = filtered.toPersistentList(), + ).convert(Unit) + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt index 86ebb93646..c19b107c11 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt @@ -4,6 +4,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase @@ -36,7 +37,7 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor( ) { private val isSearchingState: Boolean get() = searchQueryState.isSearchingState - private val onWalletSelected = Channel() + private val onWalletSelected = Channel(capacity = Channel.BUFFERED) val selectedWalletFlow: SharedFlow = onWalletSelected.receiveAsFlow() .distinctUntilChanged() @@ -47,14 +48,18 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor( .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) - private fun buildFlow() = flow { - val isNoHaveSelectedWallet = selectedWalletFlow.replayCache.isEmpty() - if (isNoHaveSelectedWallet) { - val firstSelectedWallet = selectedWalletUseCase.sync().getOrNull() - ?: getWalletsUseCase.invokeSync().first() - selectWalletTab(firstSelectedWallet.walletId) + init { + val globalSelectedWallet = selectedWalletUseCase.sync().getOrNull() + val allWallets = getWalletsUseCase.invokeSync().filter { it.isMultiCurrency } + val firstSelectedWallet = when { + globalSelectedWallet?.isMultiCurrency == true -> globalSelectedWallet + allWallets.isNotEmpty() -> allWallets.first() + else -> null } + if (firstSelectedWallet != null) selectWalletTab(firstSelectedWallet.walletId) + } + private fun buildFlow() = flow { val fullPortfolioBlockFlow = combine( flow = getWalletsUseCase.invokeAsMap(), flow2 = portfolioListBlockDelegate.portfolioList, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt index 9c932731af..ccbbf72f16 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt @@ -12,6 +12,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload +import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult @@ -26,6 +27,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* +@Suppress("LongParameterList") internal class PortfolioListBlockDelegate @AssistedInject constructor( private val expandedAccountsHolder: ChooseTokenExpandedAccountsHolder, private val settingContext: SettingContextUseCase, @@ -33,12 +35,13 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( private val getWalletsUseCase: GetWalletsUseCase, @Assisted private val modelScope: CoroutineScope, @Assisted private val searchQueryState: StateFlow, + @Assisted private val featureSettings: ChooseTokenBridge.Settings, ) : ClickIntents { private val onTokenItemClick: Channel> = Channel() val onTokenChosen: Channel = Channel() - val tokenFilter: MutableStateFlow<(AccountStatus.CryptoPortfolio, CryptoCurrencyStatus) -> Boolean> = + val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> = MutableStateFlow { _, _ -> true } val portfolioList: SharedFlow> = buildDataFlow() @@ -89,6 +92,7 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( clickIntents = this@PortfolioListBlockDelegate, searchQuery = searchQuery, tokenFilter = tokenFilter, + isShowPaymentAccount = featureSettings.isShowPaymentAccount, ).convert() um @@ -132,7 +136,11 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(searchQueryState: StateFlow, modelScope: CoroutineScope): PortfolioListBlockDelegate + fun create( + searchQueryState: StateFlow, + modelScope: CoroutineScope, + featureSettings: ChooseTokenBridge.Settings, + ): PortfolioListBlockDelegate } } From 53a84a6aa8b6e6f78e95b36c4080d0ef4b6eca2e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 11:43:05 +0300 Subject: [PATCH 063/206] Updated on 2026-08-14 --- .../com/tangem/scenarios/SendScenarios.kt | 15 +- .../tests/send/warnings/CommonWarningsTest.kt | 160 ++++++++++++++++++ 2 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CommonWarningsTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt index f7bcef3905..6f2931a598 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -3,13 +3,11 @@ package com.tangem.scenarios import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.assertIsDimmed import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.setWireMockScenarioState import com.tangem.screens.* -import com.tangem.screens.onMainScreen -import com.tangem.screens.onSendConfirmScreen -import com.tangem.screens.onSendScreen -import com.tangem.screens.onTokenDetailsScreen import io.qameta.allure.kotlin.Allure.step fun BaseTestCase.openSendScreen(tokenName: String, mockState: String = "") { @@ -72,8 +70,10 @@ fun BaseTestCase.openSendConfirmScreen( step("Type recipient address") { onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Click 'Next' button until 'Send Confirm' screen opens") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { openSendConfirmScreenViaNextButton() }.isSuccess + } } } @@ -84,6 +84,9 @@ fun BaseTestCase.openSendAddressScreen( step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } + step("Assert 'Send' button is not dimmed") { + onTokenDetailsScreen { sendButton().assertIsDimmed(false) } + } step("Click on 'Send' button") { onTokenDetailsScreen { sendButton().performClick() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CommonWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CommonWarningsTest.kt new file mode 100644 index 0000000000..7ba361bbbd --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/CommonWarningsTest.kt @@ -0,0 +1,160 @@ +package com.tangem.tests.send.warnings + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openSendConfirmScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendSelectNetworkFeeBottomSheet +import com.tangem.wallet.R +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class CommonWarningsTest : BaseTestCase() { + + @AllureId("4293") + @DisplayName("Warnings: check warning, when custom fee lower than 'Slow'") + @Test + fun warningDisplayedWhenCustomFeeLowerThanSlowTest() { + val tokenName = "Ethereum" + val sendAmount = "0.01" + val feeUpTo = getResourceString(R.string.send_max_fee) + val customFee = "0.000000001" + val warningTitle = getResourceString(R.string.send_notification_transaction_delay_title) + val warningMessage = getResourceString(R.string.send_notification_transaction_delay_text) + + setupHooks().run { + + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Confirm' screen with token: $tokenName") { + openSendConfirmScreen(tokenName, sendAmount, ETHEREUM_RECIPIENT_ADDRESS) + } + step("Click on fee selector icon") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onSendConfirmScreen { feeSelectorIcon.performClick() } + onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() } + } + } + step("Click on 'Custom' selector item") { + onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() } + } + step("Type '$customFee' into input text field") { + onSendSelectNetworkFeeBottomSheet { + inputTextFieldValue(title = feeUpTo).performClick() + inputTextFieldValue(title = feeUpTo).performTextReplacement(customFee) + } + } + step("Click on 'Done' button") { + onSendSelectNetworkFeeBottomSheet { doneButton.performClick() } + } + step("Assert 'Transaction delays are possible' warning is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = true, + sendButtonIsDisabled = false + ) + } + } + } + + @AllureId("4221") + @DisplayName("Warnings: check warning, when amount exceeds total balance") + @Test + fun warningDisplayedWhenAmountExceedsTotalBalanceTest() { + val tokenName = "Ethereum" + val sendAmount = "0.9999" + val network = "ETH 0.00032" + val amount = "\$0.81" + val warningTitle = getResourceString(R.string.send_network_fee_warning_title) + val warningMessage = getResourceString(R.string.common_network_fee_warning_content, network, amount) + + setupHooks().run { + + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + synchronizeAddresses() + } + } + step("Open 'Send Confirm' screen with token: $tokenName") { + openSendConfirmScreen(tokenName, sendAmount, ETHEREUM_RECIPIENT_ADDRESS) + } + step("Assert 'Network fee coverage' warning is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = true, + sendButtonIsDisabled = false + ) + } + } + } + + @AllureId("4294") + @DisplayName("Warnings: check warning, when custom fee is high") + @Test + fun warningDisplayedWhenCustomFeeIsHighTest() { + val tokenName = "Ethereum" + val sendAmount = "0.01" + val feeUpTo = getResourceString(R.string.send_max_fee) + val customFee = "0.004" + val timesHigher = "7" + val warningTitle = getResourceString(R.string.send_notification_fee_too_high_title) + val warningMessage = getResourceString(R.string.send_notification_fee_too_high_text, timesHigher) + + setupHooks().run { + + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Open 'Send Confirm' screen with token: $tokenName") { + openSendConfirmScreen(tokenName, sendAmount, ETHEREUM_RECIPIENT_ADDRESS) + } + step("Click on fee selector icon") { + flakySafely(WAIT_UNTIL_TIMEOUT) { + onSendConfirmScreen { feeSelectorIcon.performClick() } + onSendSelectNetworkFeeBottomSheet { customSelectorItem.assertIsDisplayed() } + } + } + step("Click on 'Custom' selector item") { + onSendSelectNetworkFeeBottomSheet { customSelectorItem.performClick() } + } + step("Type '$customFee' into input text field") { + onSendSelectNetworkFeeBottomSheet { + inputTextFieldValue(title = feeUpTo).performClick() + inputTextFieldValue(title = feeUpTo).performTextReplacement(customFee) + } + } + step("Click on 'Done' button") { + onSendSelectNetworkFeeBottomSheet { doneButton.performClick() } + } + step("Assert 'Custom fee is high' warning is displayed") { + checkSendWarning( + title = warningTitle, + message = warningMessage, + isDisplayed = true, + sendButtonIsDisabled = false + ) + } + } + } +} \ No newline at end of file From 4eb3d80df44d4f961a5b40a8f2804db436cca1a7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 09:29:46 +0000 Subject: [PATCH 064/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 94aa5668ec..f1841ba8e2 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1494" +tangemBlockchainSdk = "develop-1491" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-603" +tangemCardSdk = "develop-602" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From d9f3a8e456d3d21c490a631620751584bc1f9071 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 12:34:01 +0300 Subject: [PATCH 065/206] Updated on 2026-08-14 --- .../DefaultDynamicAddressesRepository.kt | 61 +++--- domain/dynamic-addresses/build.gradle.kts | 8 + .../DynamicAddressesDerivationChecker.kt | 52 +++++ .../repository/DynamicAddressesRepository.kt | 3 + .../DynamicAddressesDerivationCheckerTest.kt | 203 ++++++++++++++++++ features/manage-tokens/impl/build.gradle.kts | 1 + ...aultCustomTokenDerivationInputComponent.kt | 35 ++- .../DynamicAddressesDerivationValidator.kt | 51 +++++ .../tokendetails/model/TokenDetailsModel.kt | 7 +- 9 files changed, 376 insertions(+), 45 deletions(-) create mode 100644 domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationChecker.kt create mode 100644 domain/dynamic-addresses/src/test/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationCheckerTest.kt create mode 100644 features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/DynamicAddressesDerivationValidator.kt diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index 56ff5bfdb2..1830d9415f 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -1,11 +1,11 @@ package com.tangem.data.dynamicaddresses -import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.account.WalletAccountsSaver import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.network.Network @@ -97,34 +97,30 @@ internal class DefaultDynamicAddressesRepository( .flatMap { it.tokens.orEmpty() } .any { token -> val tokenDerivationPath = token.derivationPath ?: return@any false - token.networkId == network.rawId && + token.networkId == network.id.rawId.value && tokenDerivationPath != baseDerivationPath && - hasNonZeroChangeOrIndex(tokenDerivationPath, baseDerivationPath) + DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex( + customPath = tokenDerivationPath, + basePath = baseDerivationPath, + ) } } } - /** - * Checks if the token's derivation path has the same first 3 nodes (purpose/coin/account) - * as the base path but different change/index nodes (not both 0). - */ - private fun hasNonZeroChangeOrIndex(tokenPath: String, basePath: String): Boolean { - val tokenNodes = runCatching { DerivationPath(tokenPath).nodes }.getOrNull() ?: return false - val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false - - if (tokenNodes.size < DERIVATION_NODE_COUNT || baseNodes.size < DERIVATION_NODE_COUNT) return false - - // First 3 nodes must match (purpose/coin/account) by value, ignoring hardening - val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i -> - tokenNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false) - } - if (!isSameAccount) return false - - // Check if change or index ≠ 0 - val change = tokenNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false) - val index = tokenNodes[INDEX_NODE_INDEX].getIndex(includeHardened = false) - - return change != 0L || index != 0L + override fun isDynamicAddressesEnabledForNetwork( + userWalletId: UserWalletId, + networkId: Network.ID, + ): Flow { + return walletAccountsFetcher.get(userWalletId) + .map { response -> + response.accounts + .flatMap { it.tokens.orEmpty() } + .any { token -> + token.matchesNetwork(networkId) && + token.dynamicAddressesEnabled == true + } + } + .flowOn(dispatchers.io) } private suspend fun updateTokenDynamicAddressesFlag( @@ -137,7 +133,7 @@ internal class DefaultDynamicAddressesRepository( accounts = response.accounts.map { account -> account.copy( tokens = account.tokens?.map { token -> - if (token.matchesNetwork(network)) { + if (token.matchesNetwork(network.id)) { token.copy(dynamicAddressesEnabled = enabled) } else { token @@ -157,19 +153,12 @@ internal class DefaultDynamicAddressesRepository( private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? { return accounts .flatMap { it.tokens.orEmpty() } - .find { it.matchesNetwork(network) } + .find { it.matchesNetwork(network.id) } } - private fun UserTokensResponse.Token.matchesNetwork(network: Network): Boolean { - return networkId == network.rawId && - derivationPath == network.derivationPath.value && + private fun UserTokensResponse.Token.matchesNetwork(networkId: Network.ID): Boolean { + return this.networkId == networkId.rawId.value && + derivationPath == networkId.derivationPath.value && contractAddress == null } - - private companion object { - const val DERIVATION_NODE_COUNT = 5 - const val ACCOUNT_NODE_COUNT = 3 - const val CHANGE_NODE_INDEX = 3 - const val INDEX_NODE_INDEX = 4 - } } \ No newline at end of file diff --git a/domain/dynamic-addresses/build.gradle.kts b/domain/dynamic-addresses/build.gradle.kts index d366a99097..f569b12112 100644 --- a/domain/dynamic-addresses/build.gradle.kts +++ b/domain/dynamic-addresses/build.gradle.kts @@ -8,6 +8,10 @@ android { namespace = "com.tangem.domain.dynamicaddresses" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { api(projects.domain.core) api(projects.domain.dynamicAddresses.models) @@ -21,4 +25,8 @@ dependencies { exclude(module = "joda-time") } implementation(tangemDeps.card.core) + + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationChecker.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationChecker.kt new file mode 100644 index 0000000000..f2e2bf3486 --- /dev/null +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationChecker.kt @@ -0,0 +1,52 @@ +package com.tangem.domain.dynamicaddresses + +import com.tangem.crypto.hdWallet.DerivationPath + +/** + * Shared utility for checking if a derivation path conflicts with Dynamic Addresses. + * + * A path conflicts when it belongs to the same BIP44 account as the base path + * (first 3 nodes: purpose / coin_type / account match) but has non-zero + * change (node 3) or address_index (node 4). + */ +object DynamicAddressesDerivationChecker { + + private const val BIP44_NODE_COUNT = 5 + private const val ACCOUNT_NODE_COUNT = 3 + private const val CHANGE_NODE_INDEX = 3 + private const val ADDRESS_INDEX_NODE_INDEX = 4 + + /** + * @return `true` if [path] has zero change (node 3) and zero address_index (node 4). + */ + fun isBaseDerivation(path: String): Boolean { + val nodes = runCatching { DerivationPath(path).nodes }.getOrNull() ?: return false + if (nodes.size < BIP44_NODE_COUNT) return false + + val change = nodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false) + val index = nodes[ADDRESS_INDEX_NODE_INDEX].getIndex(includeHardened = false) + + return change == 0L && index == 0L + } + + /** + * @return `true` if [customPath] shares the same account as [basePath] but has + * non-zero change or address_index nodes. + */ + fun hasSameAccountWithNonZeroChangeOrIndex(customPath: String, basePath: String): Boolean { + val customNodes = runCatching { DerivationPath(customPath).nodes }.getOrNull() ?: return false + val baseNodes = runCatching { DerivationPath(basePath).nodes }.getOrNull() ?: return false + + if (customNodes.size < BIP44_NODE_COUNT || baseNodes.size < BIP44_NODE_COUNT) return false + + val isSameAccount = (0 until ACCOUNT_NODE_COUNT).all { i -> + customNodes[i].getIndex(includeHardened = false) == baseNodes[i].getIndex(includeHardened = false) + } + if (!isSameAccount) return false + + val change = customNodes[CHANGE_NODE_INDEX].getIndex(includeHardened = false) + val index = customNodes[ADDRESS_INDEX_NODE_INDEX].getIndex(includeHardened = false) + + return change != 0L || index != 0L + } +} \ No newline at end of file diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt index eec289800e..8cdf4b0c12 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt @@ -22,4 +22,7 @@ interface DynamicAddressesRepository { /** Returns true if there are custom tokens with change/index ≠ 0 that conflict with dynamic addresses */ suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean + + /** Lightweight check: is the DA flag enabled for the native coin of the given network (no xpub availability check) */ + fun isDynamicAddressesEnabledForNetwork(userWalletId: UserWalletId, networkId: Network.ID): Flow } \ No newline at end of file diff --git a/domain/dynamic-addresses/src/test/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationCheckerTest.kt b/domain/dynamic-addresses/src/test/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationCheckerTest.kt new file mode 100644 index 0000000000..e89772ef81 --- /dev/null +++ b/domain/dynamic-addresses/src/test/kotlin/com/tangem/domain/dynamicaddresses/DynamicAddressesDerivationCheckerTest.kt @@ -0,0 +1,203 @@ +package com.tangem.domain.dynamicaddresses + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DynamicAddressesDerivationCheckerTest { + + // region Conflicting: same account, non-zero change or index + + @Test + fun `same account, non-zero address index`() { + val result = check(custom = "m/44'/5'/0'/0/1", base = "m/44'/5'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `same account, non-zero change`() { + val result = check(custom = "m/44'/5'/0'/1/0", base = "m/44'/5'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `same account, both change and index non-zero`() { + val result = check(custom = "m/44'/5'/0'/1/5", base = "m/44'/5'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `same account, large address index`() { + val result = check(custom = "m/44'/5'/0'/0/8", base = "m/44'/5'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `bitcoin BIP84, non-zero index`() { + val result = check(custom = "m/84'/0'/0'/0/1", base = "m/84'/0'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `litecoin BIP84, non-zero change`() { + val result = check(custom = "m/84'/2'/0'/1/0", base = "m/84'/2'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `dogecoin, non-zero index`() { + val result = check(custom = "m/44'/3'/0'/0/8", base = "m/44'/3'/0'/0/0") + assertThat(result).isTrue() + } + + @Test + fun `bitcoin cash, non-zero index`() { + val result = check(custom = "m/44'/145'/0'/0/3", base = "m/44'/145'/0'/0/0") + assertThat(result).isTrue() + } + + // endregion + + // region Not conflicting: different account + + @Test + fun `different account index, non-zero address index`() { + val result = check(custom = "m/44'/5'/1'/0/1", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `different account index, zero change and index`() { + val result = check(custom = "m/44'/5'/1'/0/0", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `different coin type`() { + val result = check(custom = "m/44'/0'/0'/0/1", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `different purpose`() { + val result = check(custom = "m/84'/5'/0'/0/1", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `BIP44 custom against BIP84 base`() { + val result = check(custom = "m/44'/0'/0'/0/1", base = "m/84'/0'/0'/0/0") + assertThat(result).isFalse() + } + + // endregion + + // region Not conflicting: same account, zero change and index + + @Test + fun `identical paths`() { + val result = check(custom = "m/44'/5'/0'/0/0", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `identical bitcoin BIP84 paths`() { + val result = check(custom = "m/84'/0'/0'/0/0", base = "m/84'/0'/0'/0/0") + assertThat(result).isFalse() + } + + // endregion + + // region Edge cases: invalid or incomplete paths + + @Test + fun `invalid custom path`() { + val result = check(custom = "invalid", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `invalid base path`() { + val result = check(custom = "m/44'/5'/0'/0/1", base = "not_a_path") + assertThat(result).isFalse() + } + + @Test + fun `empty custom path`() { + val result = check(custom = "", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `empty base path`() { + val result = check(custom = "m/44'/5'/0'/0/1", base = "") + assertThat(result).isFalse() + } + + @Test + fun `both paths invalid`() { + val result = check(custom = "abc", base = "xyz") + assertThat(result).isFalse() + } + + @Test + fun `custom path with fewer than 5 nodes`() { + val result = check(custom = "m/44'/5'/0'", base = "m/44'/5'/0'/0/0") + assertThat(result).isFalse() + } + + @Test + fun `base path with fewer than 5 nodes`() { + val result = check(custom = "m/44'/5'/0'/0/1", base = "m/44'/5'") + assertThat(result).isFalse() + } + + // endregion + + // region isBaseDerivation + + @Test + fun `isBaseDerivation - standard BIP44 base path`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/0/0")).isTrue() + } + + @Test + fun `isBaseDerivation - BIP84 base path`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/84'/0'/0'/0/0")).isTrue() + } + + @Test + fun `isBaseDerivation - non-zero index`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/0/1")).isFalse() + } + + @Test + fun `isBaseDerivation - non-zero change`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/0'/1/0")).isFalse() + } + + @Test + fun `isBaseDerivation - non-zero account with zero change and index`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'/2'/0/0")).isTrue() + } + + @Test + fun `isBaseDerivation - invalid path`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("invalid")).isFalse() + } + + @Test + fun `isBaseDerivation - too few nodes`() { + assertThat(DynamicAddressesDerivationChecker.isBaseDerivation("m/44'/5'")).isFalse() + } + + // endregion + + private fun check(custom: String, base: String): Boolean { + return DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex( + customPath = custom, + basePath = base, + ) + } +} \ No newline at end of file diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 867d3f035c..5c8bc1a2ed 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(projects.domain.wallets.models) implementation(projects.domain.swap.models) implementation(projects.domain.notifications) + implementation(projects.domain.dynamicAddresses) // region Project - Libs implementation(projects.libs.blockchainSdk) diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt index adef3798eb..cb0043af8a 100644 --- a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/component/impl/DefaultCustomTokenDerivationInputComponent.kt @@ -7,8 +7,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import arrow.core.getOrElse import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.managetokens.ValidateDerivationPathUseCase -import com.tangem.features.managetokens.utils.CardanoDerivationPathValidator import com.tangem.domain.managetokens.model.exceptoin.DerivationPathValidationException import com.tangem.domain.models.network.Network import com.tangem.features.managetokens.component.CustomTokenDerivationInputComponent @@ -16,9 +17,12 @@ import com.tangem.features.managetokens.entity.customtoken.CustomDerivationInput import com.tangem.features.managetokens.entity.customtoken.SelectedDerivationPath import com.tangem.features.managetokens.impl.R import com.tangem.features.managetokens.ui.dialog.CustomDerivationInputDialog +import com.tangem.features.managetokens.utils.CardanoDerivationPathValidator +import com.tangem.features.managetokens.utils.DynamicAddressesDerivationValidator import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* @@ -26,9 +30,15 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr @Assisted context: AppComponentContext, @Assisted private val params: CustomTokenDerivationInputComponent.Params, private val validateDerivationPathUseCase: ValidateDerivationPathUseCase, + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, ) : CustomTokenDerivationInputComponent, AppComponentContext by context { private val cardanoDerivationPathValidator = CardanoDerivationPathValidator() + private val dynamicAddressesDerivationValidator = DynamicAddressesDerivationValidator( + dynamicAddressesRepository = dynamicAddressesRepository, + dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, + ) private val state: MutableStateFlow = MutableStateFlow( value = getInitialState(), @@ -52,13 +62,15 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr ) } - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) private fun observeValueUpdates() { state .map { it.value.text } .distinctUntilChanged() .sample(periodMillis = 1_000) - .onEach(::validateValue) + .flatMapLatest { value -> + flow { emit(validateValue(value)) } + } .launchIn(componentScope) } @@ -70,7 +82,7 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr onConfirm = ::confirm, ) - private fun validateValue(value: String) { + private suspend fun validateValue(value: String) { validateDerivationPathUseCase(value).getOrElse { e -> updateWithValidationError(e) return @@ -90,6 +102,21 @@ internal class DefaultCustomTokenDerivationInputComponent @AssistedInject constr return } + val isInvalidForDA = dynamicAddressesDerivationValidator.isInvalidForDynamicAddresses( + userWalletId = params.mode.userWalletId, + networkId = params.selectedNetwork.id, + path = value, + ) + if (isInvalidForDA) { + state.update { state -> + state.copy( + error = resourceReference(R.string.dynamic_addresses_custom_token_error_on_addition), + isConfirmEnabled = false, + ) + } + return + } + state.update { state -> state.copy( error = null, diff --git a/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/DynamicAddressesDerivationValidator.kt b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/DynamicAddressesDerivationValidator.kt new file mode 100644 index 0000000000..a7f0a8a514 --- /dev/null +++ b/features/manage-tokens/impl/src/main/kotlin/com/tangem/features/managetokens/utils/DynamicAddressesDerivationValidator.kt @@ -0,0 +1,51 @@ +package com.tangem.features.managetokens.utils + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.firstOrNull + +/** + * Validates that custom derivation paths don't conflict with Dynamic Addresses. + * + * When DA is enabled for an account, custom derivation paths with non-zero + * change (node 3) or address_index (node 4) are forbidden, because DA + * manages those nodes automatically. + */ +internal class DynamicAddressesDerivationValidator( + private val dynamicAddressesRepository: DynamicAddressesRepository, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, +) { + + /** + * @return true if the path is invalid (DA is enabled for the same account and change/index ≠ 0) + */ + suspend fun isInvalidForDynamicAddresses( + userWalletId: UserWalletId, + networkId: Network.ID, + path: String?, + ): Boolean { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false + if (path == null) return false + if (!isSupportedBlockchain(networkId)) return false + + val basePath = networkId.derivationPath.value ?: return false + val isEnabled = dynamicAddressesRepository + .isDynamicAddressesEnabledForNetwork(userWalletId, networkId) + .firstOrNull() == true + if (!isEnabled) return false + + return DynamicAddressesDerivationChecker.hasSameAccountWithNonZeroChangeOrIndex( + customPath = path, + basePath = basePath, + ) + } + + private fun isSupportedBlockchain(networkId: Network.ID): Boolean { + return DynamicAddressesSupportedBlockchains.isSupported(networkId.toBlockchain()) + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 2a3f0c895f..d7f1bb3ec4 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -9,6 +9,7 @@ import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.address.AddressType import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase @@ -551,11 +552,7 @@ internal class TokenDetailsModel @Inject constructor( val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false - val changeNode = nodes[nodes.size - 2] - val indexNode = nodes.last() - - return changeNode.getIndex(includeHardened = false) == 0L && - indexNode.getIndex(includeHardened = false) == 0L + return DynamicAddressesDerivationChecker.isBaseDerivation(pathValue) } private suspend fun isXPUBSupported(): Boolean { From 943baed9b83de8f80b555b034b3b3933aed6a224 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 12:38:40 +0300 Subject: [PATCH 066/206] Updated on 2026-08-14 --- .../DefaultDynamicAddressesRepository.kt | 8 +-- .../DefaultWalletManagersFacade.kt | 49 ++++++++++++------- .../walletmanager/WalletManagersFacade.kt | 2 + 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index 1830d9415f..9f36682a75 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -34,7 +34,8 @@ internal class DefaultDynamicAddressesRepository( val token = response.findToken(network) when { token?.dynamicAddressesEnabled != true -> DynamicAddressesStatus.DISABLED - !isXpubAvailable(userWalletId, network) -> DynamicAddressesStatus.ENABLED_REQUIRES_SETUP + !walletManagersFacade.isDynamicAddressesEnabled(userWalletId, network) -> + DynamicAddressesStatus.ENABLED_REQUIRES_SETUP else -> DynamicAddressesStatus.ENABLED } } @@ -145,11 +146,6 @@ internal class DefaultDynamicAddressesRepository( } } - private suspend fun isXpubAvailable(userWalletId: UserWalletId, network: Network): Boolean { - // Check if WalletManager is already in XPUB mode (dynamic addresses were previously enabled on this device) - return walletManagersFacade.getDynamicAddressesReceiveAddress(userWalletId, network) != null - } - private fun GetWalletAccountsResponse.findToken(network: Network): UserTokensResponse.Token? { return accounts .flatMap { it.tokens.orEmpty() } diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index c23bb4c58e..3401f5d920 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -209,11 +209,14 @@ internal class DefaultWalletManagersFacade @Inject constructor( "Unable to get a wallet manager for blockchain: $blockchain" } - val address = walletManager - .wallet - .addresses - .find { it.type == addressType } - ?.value ?: walletManager.wallet.address + val isDynamicAddressesEnabled = (walletManager as? DynamicAddressesManager)?.isDynamicAddressesEnabled == true + + val address = if (isDynamicAddressesEnabled) { + getDynamicAddressesLastUsedReceiveAddress(userWalletId, network) ?: walletManager.wallet.address + } else { + walletManager.wallet.addresses.find { it.type == addressType }?.value ?: walletManager.wallet.address + } + return blockchain.getExploreUrl(address, contractAddress) } @@ -466,9 +469,12 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + override suspend fun isDynamicAddressesEnabled(userWalletId: UserWalletId, network: Network): Boolean { + return getEnabledDynamicAddressesManagerOrNull(userWalletId, network) != null + } + override suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? { - val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) - val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return null + val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address } @@ -476,23 +482,21 @@ internal class DefaultWalletManagersFacade @Inject constructor( userWalletId: UserWalletId, network: Network, ): String? { - val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) - val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return null + val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null return dynamicAddressesManager.usedAddresses - .filter { usedAddress -> + .mapNotNull { usedAddress -> val nodes = runCatching { DerivationPath(usedAddress.derivationPath).nodes }.getOrNull() - ?: return@filter false - nodes.size >= XPUB_PATH_MIN_NODES && nodes[nodes.size - 2].index == RECEIVE_CHAIN_INDEX + ?: return@mapNotNull null + if (nodes.size < XPUB_PATH_MIN_NODES) return@mapNotNull null + if (nodes[nodes.size - 2].index != RECEIVE_CHAIN_INDEX) return@mapNotNull null + usedAddress to nodes.last().index } - .maxByOrNull { usedAddress -> - runCatching { DerivationPath(usedAddress.derivationPath).nodes.last().index }.getOrDefault(0L) - } - ?.address + .maxByOrNull { (_, lastIndex) -> lastIndex } + ?.first?.address } override suspend fun hasDynamicAddressesNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean { - val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) - val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return false + val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return false return dynamicAddressesManager.usedAddresses.any { usedAddress -> val nodes = runCatching { DerivationPath(usedAddress.derivationPath).nodes }.getOrNull() ?: return@any false @@ -503,8 +507,17 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + private suspend fun getEnabledDynamicAddressesManagerOrNull( + userWalletId: UserWalletId, + network: Network, + ): DynamicAddressesManager? { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + return (walletManager as? DynamicAddressesManager)?.takeIf { it.isDynamicAddressesEnabled } + } + private fun restoreXpubModeIfNeeded(walletManager: WalletManager, xpub: String) { val dynamicAddressesManager = walletManager as? DynamicAddressesManager ?: return + if (dynamicAddressesManager.isDynamicAddressesEnabled) return try { dynamicAddressesManager.enableDynamicAddresses(xpub) diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index 92824ca373..854c807db6 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -295,6 +295,8 @@ interface WalletManagersFacade { suspend fun disableXpubMode(userWalletId: UserWalletId, network: Network): SimpleResult + suspend fun isDynamicAddressesEnabled(userWalletId: UserWalletId, network: Network): Boolean + suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? suspend fun getDynamicAddressesLastUsedReceiveAddress(userWalletId: UserWalletId, network: Network): String? From 5e12c2dc29636443e0060517d65f590e75b57c13 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 11:40:26 +0200 Subject: [PATCH 067/206] Updated on 2026-08-14 --- .../core/analytics/models/AnalyticsParam.kt | 1 + .../components/DefaultFeedEntryComponent.kt | 4 +- .../feed/components/FeedEntryChildFactory.kt | 5 +- .../components/earn/DefaultEarnComponent.kt | 2 +- .../list/DefaultMarketsTokenListComponent.kt | 2 +- .../search/DefaultSearchComponent.kt | 1 + .../features/feed/model/earn/EarnModel.kt | 3 +- .../feed/model/feed/FeedComponentModel.kt | 2 +- .../feed/model/feed/FeedModelClickIntents.kt | 2 +- .../model/market/list/MarketsListModel.kt | 3 +- .../MarketsListBatchFlowManager.kt | 15 ++++ .../features/feed/model/search/SearchModel.kt | 61 +++++++++++++- .../search/analytics/SearchAnalyticsEvent.kt | 84 +++++++++++++++++++ .../search/analytics/SearchAnalyticsHelper.kt | 64 ++++++++++++++ 14 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsEvent.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsHelper.kt diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index a670a8226a..d6b77eb6ba 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -99,6 +99,7 @@ sealed class AnalyticsParam { data object NewsPage : ScreensSources("News Page") data object Portfolio : ScreensSources("Portfolio") data object Staking : ScreensSources("Staking") + data object Earn : ScreensSources("Earn") } sealed class TxSentFrom(val value: String) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 74271e53a0..a0b3a03088 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -132,8 +132,8 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( innerRouter.push(FeedEntryChildFactory.Child.Earn) } - override fun openSearch() { - innerRouter.push(FeedEntryChildFactory.Child.Search) + override fun openSearch(source: String) { + innerRouter.push(FeedEntryChildFactory.Child.Search(source)) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index ef34cba3d1..e8f7af83ef 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -65,7 +65,7 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable - data object Search : Child + data class Search(val source: String) : Child } @Suppress("LongMethod") @@ -144,7 +144,7 @@ internal class FeedEntryChildFactory @Inject constructor( addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, ) } - Child.Search -> DefaultSearchComponent( + is Child.Search -> DefaultSearchComponent( appComponentContext = appComponentContext, params = DefaultSearchComponent.Params( onBackClick = onBackClicked, @@ -155,6 +155,7 @@ internal class FeedEntryChildFactory @Inject constructor( source = AnalyticsParam.ScreensSources.Market.value, ) }, + sourceParams = child.source, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index 86bb48edfb..8ddecd2dc6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -148,6 +148,6 @@ internal class DefaultEarnComponent( data class Params( val onBackClick: () -> Unit, - val onSearchClicked: () -> Unit, + val onSearchClicked: (source: String) -> Unit, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt index 29e359843b..79cbf99fa3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/list/DefaultMarketsTokenListComponent.kt @@ -134,7 +134,7 @@ internal class DefaultMarketsTokenListComponent( data class ClickIntents( val onBackClicked: () -> Unit, - val onSearchClicked: () -> Unit, + val onSearchClicked: (source: String) -> Unit, val onTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt index 45392661d4..0ad95b2ae6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/DefaultSearchComponent.kt @@ -120,5 +120,6 @@ internal class DefaultSearchComponent( data class Params( val onBackClick: () -> Unit, val onMarketTokenClick: ((TokenMarketParams, AppCurrency) -> Unit), + val sourceParams: String, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index b69171f8f3..9ecb1ab5f4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -8,6 +8,7 @@ import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -325,7 +326,7 @@ internal class EarnModel @Inject constructor( onNetworkFilterClick = ::onNetworkFilterClick, onTypeFilterClick = ::onTypeFilterClick, onScroll = ::onMostlyUsedScrolled, - onSearchBarClicked = params.onSearchClicked, + onSearchBarClicked = { params.onSearchClicked(AnalyticsParam.ScreensSources.Earn.value) }, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index c3052275b3..b1d45c9693 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -240,7 +240,7 @@ internal class FeedComponentModel @Inject constructor( onBarClick = { analyticsEventHandler.send(FeedAnalyticsEvent.TokenSearchedClicked()) if (designFeatureToggles.isRedesignEnabled) { - params.feedClickIntents.openSearch() + params.feedClickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value) } else { params.feedClickIntents.onMarketOpenClick(null) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 96ff7cc2f1..7c81362bf3 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -30,5 +30,5 @@ internal interface FeedModelClickIntents { fun onOpenEarnPage() - fun openSearch() + fun openSearch(source: String) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt index 79c0d6a2db..dfd35cd4a7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/MarketsListModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -70,7 +71,7 @@ internal class MarketsListModel @Inject constructor( preselectedInterval = Provider { modelParams.params.preselectedInterval }, onBackClick = modelParams.clickIntents.onBackClicked, analyticsEventHandler = analyticsEventHandler, - onSearchBarClick = modelParams.clickIntents.onSearchClicked, + onSearchBarClick = { modelParams.clickIntents.onSearchClicked(AnalyticsParam.ScreensSources.Market.value) }, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt index 781dceeb6d..c7b455abfe 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/list/statemanager/MarketsListBatchFlowManager.kt @@ -141,6 +141,21 @@ internal class MarketsListBatchFlowManager( initialValue = false, ) + val initialLoadingError: Flow = batchFlow.state + .map { it.status } + .distinctUntilChanged() + .filterIsInstance() + .map { it.throwable } + + val totalCount: StateFlow = batchFlow.state + .map { it.totalCount } + .distinctUntilChanged() + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) + val isSearchNotFoundState = batchFlow.state .map { batchListState -> currentSearchText().isNullOrEmpty().not() && diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt index 839d5877f7..8dc79c7ec2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt @@ -13,6 +13,7 @@ import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -28,6 +29,7 @@ import com.tangem.features.feed.components.search.SearchBottomSheetRoute import com.tangem.features.feed.model.market.list.state.MarketsListUM import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.model.market.list.statemanager.MarketsListBatchFlowManager +import com.tangem.features.feed.model.search.analytics.SearchAnalyticsHelper import com.tangem.features.feed.model.search.converter.* import com.tangem.features.feed.model.search.state.SearchStateController import com.tangem.features.feed.model.search.state.transformers.* @@ -47,6 +49,7 @@ import javax.inject.Inject private const val UPDATE_QUOTES_TIMER_MILLIS = 60000L private const val MARKET_SEARCH_DEBOUNCE_MS = 500L +private const val RESULTS_SHOWN_DEBOUNCE_MS = 1000L @Suppress("LongParameterList", "LargeClass") @ModelScoped @@ -63,6 +66,7 @@ internal class SearchModel @Inject constructor( private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val appRouter: AppRouter, private val stateController: SearchStateController, + private val searchAnalyticsHelper: SearchAnalyticsHelper, ) : Model() { private val params = paramsContainer.require() @@ -71,7 +75,6 @@ internal class SearchModel @Inject constructor( private val searchResultsJob = JobHolder() private val marketSearchDebounceJob = JobHolder() private var shouldShowAllTokensIncludingUnder100k = false - private val currentAppCurrency = getSelectedAppCurrencyUseCase().map { maybeAppCurrency -> maybeAppCurrency.getOrElse { AppCurrency.Default } }.stateIn( @@ -122,7 +125,10 @@ internal class SearchModel @Inject constructor( subscribeToMarketUiItems() subscribeToQuotesPolling() subscribeToAppCurrencyChanges() + subscribeToMarketLoadingErrors() + subscribeToResultsShown() loadHistory() + searchAnalyticsHelper.sendSearchScreenOpened(params.sourceParams) } fun loadMore() { @@ -138,11 +144,14 @@ internal class SearchModel @Inject constructor( fun clearSearchHistory() { modelScope.launch(dispatchers.default) { clearSearchHistoryUseCase() + searchAnalyticsHelper.sendClearButtonClicked() } } fun onTextHintClick(text: String) { stateController.update(UpdateSearchBarQueryTransformer(text)) + searchAnalyticsHelper.sendHintClicked(text) + searchAnalyticsHelper.sendSearchStarted() } fun onResultMarketTokenClick(item: MarketsListItemUM) { @@ -155,6 +164,7 @@ internal class SearchModel @Inject constructor( ) saveRecentSearchTokenUseCase(marketsListItemToRecentSearchTokenConverter.convert(input)) saveSearchQueryUseCase(stateController.value.searchBar.query) + searchAnalyticsHelper.sendMarketItemClicked(item.currencySymbol) withContext(dispatchers.mainImmediate) { searchMarketsListManager.getTokenById(item.id)?.let { found -> params.onMarketTokenClick(found.toSerializableParam(), appCurrency) @@ -176,6 +186,7 @@ internal class SearchModel @Inject constructor( ), imageUrl = item.iconUrl, ) + searchAnalyticsHelper.sendRecentItemClicked(item.currencySymbol) params.onMarketTokenClick(tokenMarketParams, currentAppCurrency.value) } @@ -195,6 +206,7 @@ internal class SearchModel @Inject constructor( private fun onQueryChange(query: String) { stateController.update(UpdateSearchBarQueryTransformer(query)) + searchAnalyticsHelper.sendSearchStarted() } private fun onClearClick() { @@ -202,6 +214,7 @@ internal class SearchModel @Inject constructor( } private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) { + searchAnalyticsHelper.sendPortfolioItemClicked(entry.currencyStatus.currency.symbol) appRouter.push( AppRoute.CurrencyDetails( userWalletId = entry.userWalletId, @@ -211,6 +224,7 @@ internal class SearchModel @Inject constructor( } private fun onGroupedUserAssetClick(grouped: UserAssetSearchItem.Grouped) { + searchAnalyticsHelper.sendGroupClicked(grouped.tokenSymbol) bottomSheetNavigation.activate( SearchBottomSheetRoute.TokenSelector( entries = grouped.entries, @@ -291,6 +305,50 @@ internal class SearchModel @Inject constructor( }.launchIn(modelScope) } + private fun subscribeToMarketLoadingErrors() { + searchMarketsListManager.initialLoadingError + .onEach { throwable -> + val (code, message) = when (throwable) { + is ApiResponseError.HttpException -> + throwable.code.numericCode to throwable.message.orEmpty() + else -> null to throwable.message.orEmpty() + } + searchAnalyticsHelper.sendErrorMarketsData(code, message) + } + .launchIn(modelScope) + } + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + private fun subscribeToResultsShown() { + stateController.uiState + .map { it.searchBar.query.trim() } + .distinctUntilChanged() + .flatMapLatest { query -> + if (query.isEmpty()) { + emptyFlow() + } else { + combine( + searchMarketsListManager.totalCount.filterNotNull(), + stateController.uiState.map { state -> + (state.content as? SearchContentUM.Results)?.userAssets?.size ?: 0 + }.distinctUntilChanged(), + ) { marketCount, userAssetsCount -> + marketCount to userAssetsCount + } + .debounce(RESULTS_SHOWN_DEBOUNCE_MS) + .take(1) + } + } + .onEach { (marketCount, userAssetsCount) -> + searchAnalyticsHelper.sendResultShown( + totalResultsCount = marketCount + userAssetsCount, + marketsResultsCount = marketCount, + userTokensResultsCount = userAssetsCount, + ) + } + .launchIn(modelScope) + } + private fun subscribeToMarketUiItems() { combine( flow = stateController.uiState.map { it.searchBar.query }.distinctUntilChanged(), @@ -312,6 +370,7 @@ internal class SearchModel @Inject constructor( ) } .filterNotNull() + .distinctUntilChanged() .onEach { snapshot -> stateController.update(ApplySearchMarketBatchTransformer(snapshot)) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsEvent.kt new file mode 100644 index 0000000000..9abc00632c --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsEvent.kt @@ -0,0 +1,84 @@ +package com.tangem.features.feed.model.search.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE +import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE +import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM +import com.tangem.core.analytics.models.IS_NOT_HTTP_ERROR + +internal sealed class SearchAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Search", event = event, params = params) { + + data class SearchScreenOpened( + private val screensSource: String, + ) : SearchAnalyticsEvent( + event = "Search Screen Opened", + params = mapOf(SOURCE to screensSource), + ) + + class SearchStarted : SearchAnalyticsEvent(event = "Search Started") + + data class ResultsShown( + private val totalResultsCount: Int, + private val marketsResultsCount: Int, + private val userTokensResultsCount: Int, + ) : SearchAnalyticsEvent( + event = "Results Shown", + params = mapOf( + "Total Results" to totalResultsCount.toString(), + "User Tokens Count" to userTokensResultsCount.toString(), + "Market Tokens Count" to marketsResultsCount.toString(), + ), + ) + + data class ErrorMarketsData( + private val code: Int?, + private val message: String, + ) : SearchAnalyticsEvent( + event = "Error - Markets Data", + params = mapOf( + ERROR_CODE to (code ?: IS_NOT_HTTP_ERROR).toString(), + ERROR_MESSAGE to message, + ), + ) + + data class PortfolioItemClicked( + private val tokenSymbol: String, + ) : SearchAnalyticsEvent( + event = "Portfolio Item Clicked", + params = mapOf(TOKEN_PARAM to tokenSymbol), + ) + + data class HintClicked( + private val hint: String, + ) : SearchAnalyticsEvent( + event = "Hint Clicked", + params = mapOf("Text" to hint), + ) + + data class RecentItemClicked( + private val tokenSymbol: String, + ) : SearchAnalyticsEvent( + event = "Recent Item Clicked", + params = mapOf(TOKEN_PARAM to tokenSymbol), + ) + + data class MarketItemClicked( + private val tokenSymbol: String, + ) : SearchAnalyticsEvent( + event = "Market Item Clicked", + params = mapOf(TOKEN_PARAM to tokenSymbol), + ) + + data class GroupClicked( + private val tokenSymbol: String, + ) : SearchAnalyticsEvent( + event = "Group Clicked", + params = mapOf(TOKEN_PARAM to tokenSymbol), + ) + + class ButtonClearHistoryClick : SearchAnalyticsEvent(event = "Button - Clear History") +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsHelper.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsHelper.kt new file mode 100644 index 0000000000..5a1f8d71a1 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/analytics/SearchAnalyticsHelper.kt @@ -0,0 +1,64 @@ +package com.tangem.features.feed.model.search.analytics + +import com.tangem.core.analytics.api.AnalyticsEventHandler +import javax.inject.Inject + +class SearchAnalyticsHelper @Inject constructor( + private val analyticsEventHandler: AnalyticsEventHandler, +) { + + private var isSearchStartedWasSent = false + + fun sendSearchScreenOpened(source: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.SearchScreenOpened(source)) + } + + fun sendMarketItemClicked(tokenSymbol: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.MarketItemClicked(tokenSymbol)) + } + + fun sendClearButtonClicked() { + analyticsEventHandler.send(SearchAnalyticsEvent.ButtonClearHistoryClick()) + } + + fun sendHintClicked(text: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.HintClicked(text)) + } + + fun sendRecentItemClicked(tokenSymbol: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.RecentItemClicked(tokenSymbol)) + } + + fun sendSearchStarted() { + if (isSearchStartedWasSent) return + analyticsEventHandler.send(SearchAnalyticsEvent.SearchStarted()) + isSearchStartedWasSent = true + } + + fun sendPortfolioItemClicked(tokenSymbol: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.PortfolioItemClicked(tokenSymbol)) + } + + fun sendGroupClicked(tokenSymbol: String) { + analyticsEventHandler.send(SearchAnalyticsEvent.GroupClicked(tokenSymbol)) + } + + fun sendResultShown(totalResultsCount: Int, marketsResultsCount: Int, userTokensResultsCount: Int) { + analyticsEventHandler.send( + SearchAnalyticsEvent.ResultsShown( + totalResultsCount = totalResultsCount, + marketsResultsCount = marketsResultsCount, + userTokensResultsCount = userTokensResultsCount, + ), + ) + } + + fun sendErrorMarketsData(code: Int?, message: String) { + analyticsEventHandler.send( + SearchAnalyticsEvent.ErrorMarketsData( + code = code, + message = message, + ), + ) + } +} \ No newline at end of file From af8544e6d7295a3f6eba6622ff3923357a441515 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Apr 2026 11:50:14 +0300 Subject: [PATCH 068/206] Updated on 2026-08-14 --- .../com/tangem/common/ui/earn/EarnBlock.kt | 315 ++++++++++++++++++ .../com/tangem/common/ui/earn/EarnBlockUM.kt | 71 ++++ .../tangem/core/ui/ds/button/TangemButton.kt | 2 +- .../model/TokenDetailsDialogFactory.kt | 16 + .../tokendetails/model/TokenDetailsModel.kt | 116 ++++--- .../state/TokenDetailsStateController.kt | 3 +- .../tokendetails/state/TokenDetailsUM.kt | 5 +- .../UpdateNotificationsTransformer.kt | 225 +++++++++++++ .../UpdateStakingNotificationTransformer.kt | 298 +++++++++++++++++ .../tokendetails/ui/TokenDetailsScreen.kt | 46 ++- .../ui/TokenDetailsScreenLegacy.kt | 4 +- ...ingBlock.kt => TokenStakingBlockLegacy.kt} | 12 +- 12 files changed, 1048 insertions(+), 65 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt create mode 100644 common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt create mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt rename features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/{TokenStakingBlock.kt => TokenStakingBlockLegacy.kt} (95%) diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt new file mode 100644 index 0000000000..916fe0e737 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt @@ -0,0 +1,315 @@ +package com.tangem.common.ui.earn + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.innerShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.ds.button.TangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.resolveAnnotatedReference +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.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.res.R as CoreResR + +private const val TINTED_BACKGROUND_ALPHA = 0.1f +private const val TINTED_BORDER_ALPHA = 0.1f +private const val TINTED_INNER_SHADOW_ALPHA = 0.3f +private val BorderWidth = 1.dp +private val InnerShadowBlur = 20.dp +private val ShimmerSubtitleWidth = 78.dp + +@Composable +fun EarnBlock(state: EarnBlockUM, modifier: Modifier = Modifier) { + when (state) { + is EarnBlockUM.Loading -> EarnBlockLoading(modifier) + is EarnBlockUM.Content -> EarnBlockContent(state, modifier) + } +} + +@Composable +private fun EarnBlockLoading(modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(TangemTheme.dimens2.x4) + TangemRowContainer( + modifier = modifier + .clip(shape) + .background(TangemTheme.colors2.surface.level3) + .border(width = BorderWidth, color = TangemTheme.colors2.border.neutral.primary, shape = shape), + contentPadding = PaddingValues(all = TangemTheme.dimens2.x3), + content = { + CircleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3) + .size(TangemTheme.dimens2.x10), + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .size(width = TangemTheme.dimens2.x16, height = TangemTheme.dimens2.x5), + radius = TangemTheme.dimens2.x2, + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .size(width = ShimmerSubtitleWidth, height = TangemTheme.dimens2.x4), + radius = TangemTheme.dimens2.x2, + ) + }, + ) +} + +@Composable +private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(TangemTheme.dimens2.x4) + + val backgroundModifier = resolveBackgroundModifier(state.backgroundUM, shape) + + val clickModifier = when (val trailing = state.trailingUM) { + is EarnBlockUM.TrailingUM.Balance -> Modifier.clickable(onClick = trailing.onClick) + else -> Modifier + } + + TangemRowContainer( + modifier = modifier + .clip(shape) + .then(backgroundModifier) + .then(clickModifier), + contentPadding = PaddingValues(all = TangemTheme.dimens2.x3), + content = { + // Icon (HEAD) + EarnBlockIcon( + iconUM = state.iconUM, + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3), + ) + + // Title (START_TOP) + Text( + text = state.titleUM.text.resolveReference(), + style = when (state.titleUM.style) { + EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 + }, + color = state.titleUM.color(), + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .padding(end = TangemTheme.dimens2.x2), + ) + + // Subtitle (START_BOTTOM) + val subtitle = state.subtitleUM + if (subtitle is EarnBlockUM.SubtitleUM.Text) { + Text( + text = subtitle.text.resolveReference(), + style = when (subtitle.style) { + EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 + }, + color = subtitle.color(), + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .padding(end = TangemTheme.dimens2.x2), + ) + } + + // Trailing + EarnBlockTrailing(trailingUM = state.trailingUM) + }, + ) +} + +@Composable +private fun resolveBackgroundModifier(backgroundUM: EarnBlockUM.BackgroundUM, shape: RoundedCornerShape): Modifier { + return when (backgroundUM) { + is EarnBlockUM.BackgroundUM.Surface -> Modifier + .background(TangemTheme.colors2.surface.level3) + .border(width = BorderWidth, color = TangemTheme.colors2.border.neutral.primary, shape = shape) + is EarnBlockUM.BackgroundUM.Tinted -> { + val tintColor = backgroundUM.color() + Modifier + .background(tintColor.copy(alpha = TINTED_BACKGROUND_ALPHA)) + .border(width = BorderWidth, color = tintColor.copy(alpha = TINTED_BORDER_ALPHA), shape = shape) + .innerShadow( + shape = shape, + shadow = Shadow( + radius = InnerShadowBlur, + color = tintColor.copy(alpha = TINTED_INNER_SHADOW_ALPHA), + offset = DpOffset.Zero, + ), + ) + } + } +} + +@Composable +private fun EarnBlockTrailing(trailingUM: EarnBlockUM.TrailingUM?) { + when (trailingUM) { + is EarnBlockUM.TrailingUM.Button -> { + TangemButton( + buttonUM = trailingUM.buttonUM, + modifier = Modifier.layoutId(TangemRowLayoutId.TAIL), + ) + } + is EarnBlockUM.TrailingUM.Balance -> { + if (!trailingUM.isBalanceHidden) { + Text( + text = trailingUM.fiatValue.resolveAnnotatedReference(), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), + ) + Text( + text = trailingUM.cryptoValue.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), + ) + } + } + null -> Unit + } +} + +@Composable +private fun EarnBlockIcon(iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.size(TangemTheme.dimens2.x10), + ) { + if (iconUM is EarnBlockUM.IconUM.Glowing) { + val glowShape = RoundedCornerShape(percent = 50) + Box( + modifier = Modifier + .size(TangemTheme.dimens2.x6) + .blur(radius = TangemTheme.dimens2.x4, edgeTreatment = BlurredEdgeTreatment.Unbounded) + .background(color = iconUM.glowColor().copy(alpha = 0.7f), shape = glowShape), + ) + } + val iconRes = when (iconUM) { + is EarnBlockUM.IconUM.Glowing -> iconUM.iconRes + is EarnBlockUM.IconUM.Plain -> iconUM.iconRes + } + TangemIcon( + tangemIconUM = TangemIconUM.Image(imageRes = iconRes), + modifier = Modifier.size(TangemTheme.dimens2.x10), + ) + } +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun EarnBlock_Preview(@PreviewParameter(EarnBlockPreviewProvider::class) state: EarnBlockUM) { + TangemThemePreviewRedesign { + EarnBlock( + state = state, + modifier = Modifier.padding(TangemTheme.dimens2.x4), + ) + } +} + +private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider( + collection = listOf( + EarnBlockUM.Loading, + EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Plain(iconRes = R.drawable.ic_staking_disable_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.staking_native), + style = EarnBlockUM.TitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.tertiary }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.staking_notification_network_error_text), + style = EarnBlockUM.SubtitleUM.Style.Small, + color = { TangemTheme.colors2.text.neutral.tertiary }, + ), + trailingUM = null, + ), + EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Tinted { TangemTheme.colors2.border.status.accent }, + iconUM = EarnBlockUM.IconUM.Glowing( + iconRes = R.drawable.ic_staking_40, + glowColor = { TangemTheme.colors2.border.status.accent }, + ), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.token_details_staking_block_title), + style = EarnBlockUM.TitleUM.Style.Small, + color = { TangemTheme.colors2.text.status.accent }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stringReference("Average APR 5.24%"), + style = EarnBlockUM.SubtitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.primary }, + ), + trailingUM = EarnBlockUM.TrailingUM.Button( + buttonUM = TangemButtonUM( + text = stringReference("Stake"), + type = TangemButtonType.Accent, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + onClick = {}, + ), + ), + ), + EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing( + iconRes = R.drawable.ic_staking_40, + glowColor = { TangemTheme.colors2.text.status.accent }, + ), + titleUM = EarnBlockUM.TitleUM( + text = stringReference("Native staking"), + style = EarnBlockUM.TitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.primary }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stringReference("$ 12.34 rewards"), + style = EarnBlockUM.SubtitleUM.Style.Small, + color = { TangemTheme.colors2.text.status.accent }, + ), + trailingUM = EarnBlockUM.TrailingUM.Balance( + fiatValue = stringReference("$ 500.17"), + cryptoValue = stringReference("500.00 SOL"), + isBalanceHidden = false, + onClick = {}, + ), + ), + ), +) +// endregion \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt new file mode 100644 index 0000000000..adfd69841f --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlockUM.kt @@ -0,0 +1,71 @@ +package com.tangem.common.ui.earn + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.ColorReference2 +import com.tangem.core.ui.extensions.TextReference + +@Immutable +sealed interface EarnBlockUM { + + data object Loading : EarnBlockUM + + data class Content( + val backgroundUM: BackgroundUM, + val iconUM: IconUM, + val titleUM: TitleUM, + val subtitleUM: SubtitleUM?, + val trailingUM: TrailingUM?, + ) : EarnBlockUM + + @Immutable + sealed interface BackgroundUM { + data object Surface : BackgroundUM + data class Tinted(val color: ColorReference2) : BackgroundUM + } + + @Immutable + sealed interface IconUM { + data class Glowing( + @DrawableRes val iconRes: Int, + val glowColor: ColorReference2, + ) : IconUM + + data class Plain( + @DrawableRes val iconRes: Int, + ) : IconUM + } + + @Immutable + data class TitleUM( + val text: TextReference, + val style: Style, + val color: ColorReference2, + ) { + enum class Style { Large, Small } + } + + @Immutable + sealed interface SubtitleUM { + data class Text( + val text: TextReference, + val style: Style, + val color: ColorReference2, + ) : SubtitleUM + + enum class Style { Large, Small } + } + + @Immutable + sealed interface TrailingUM { + data class Button(val buttonUM: TangemButtonUM) : TrailingUM + + data class Balance( + val fiatValue: TextReference, + val cryptoValue: TextReference, + val isBalanceHidden: Boolean, + val onClick: () -> Unit, + ) : TrailingUM + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt index 60ac1aebea..7c51476e5b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/button/TangemButton.kt @@ -47,7 +47,7 @@ fun TangemButton(buttonUM: TangemButtonUM, modifier: Modifier = Modifier) { iconPosition = buttonUM.iconPosition, isEnabled = buttonUM.isEnabled, isLoading = buttonUM.isLoading, - type = TangemButtonType.Positive, + type = TangemButtonType.Accent, size = buttonUM.size, shape = buttonUM.shape, ) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt index df70f292b1..7a986acecf 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsDialogFactory.kt @@ -70,4 +70,20 @@ internal class TokenDetailsDialogFactory @Inject constructor( fun showError(text: TextReference) { uiMessageSender.send(DialogMessage(message = text)) } + + fun showConfirmHideExpressStatus(onConfirm: () -> Unit) { + uiMessageSender.send( + DialogMessage( + title = resourceReference(R.string.express_status_hide_dialog_title), + message = resourceReference(R.string.express_status_hide_dialog_text), + firstActionBuilder = { + EventMessageAction( + title = resourceReference(R.string.common_hide), + onClick = onConfirm, + ) + }, + secondActionBuilder = { cancelAction() }, + ), + ) + } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index d7f1bb3ec4..11185ac5f1 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -42,8 +42,6 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.haptic.VibratorHapticManager -import com.tangem.core.ui.message.DialogMessage -import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase @@ -110,6 +108,8 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.transform import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateStakingNotificationTransformer +import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateNotificationsTransformer import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R @@ -128,6 +128,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @Suppress("LongParameterList", "LargeClass", "TooManyFunctions", "PropertyUsedBeforeDeclaration") +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) @Stable @ModelScoped internal class TokenDetailsModel @Inject constructor( @@ -204,6 +205,7 @@ internal class TokenDetailsModel @Inject constructor( private val yieldSupplyBalanceJobHolder = JobHolder() private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() private val redesignBalanceJobHolder = JobHolder() + private val redesignEarnJobHolder = JobHolder() private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null private var account: Account.CryptoPortfolio? = null @@ -386,6 +388,13 @@ internal class TokenDetailsModel @Inject constructor( val updatedState = stateFactory.getStateWithNotifications(warnings) notificationsAnalyticsSender.send(uiState.value, updatedState.notifications) uiState.value = updatedState + + redesignStateController.update( + UpdateNotificationsTransformer( + warnings = warnings, + clickIntents = this@TokenDetailsModel, + ), + ) } .launchIn(modelScope) .saveIn(warningsJobHolder) @@ -797,9 +806,9 @@ internal class TokenDetailsModel @Inject constructor( ) if (canHide) { - showConfirmHideTokenDialog(cryptoCurrency) + dialogFactory.showConfirmHideToken(currency = cryptoCurrency, onConfirm = ::onHideConfirmed) } else { - showLinkedTokensDialog(cryptoCurrency) + dialogFactory.showLinkedTokens(currency = cryptoCurrency) } } } @@ -1021,7 +1030,7 @@ internal class TokenDetailsModel @Inject constructor( } } if (message != null) { - showErrorDialog(stringReference(message)) + dialogFactory.showError(text = stringReference(message)) TangemLogger.e(message) } }, @@ -1065,7 +1074,7 @@ internal class TokenDetailsModel @Inject constructor( } if (message != null) { - showErrorDialog(message) + dialogFactory.showError(text = message) } }, ifRight = { uiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() }, @@ -1080,7 +1089,9 @@ internal class TokenDetailsModel @Inject constructor( blockchain = cryptoCurrency.network.name, ), ) - showDismissIncompleteTransactionConfirmDialog() + dialogFactory.showDismissIncompleteTransactionConfirm( + onConfirm = ::onConfirmDismissIncompleteTransactionClick, + ) } override fun onConfirmDismissIncompleteTransactionClick() { @@ -1090,7 +1101,7 @@ internal class TokenDetailsModel @Inject constructor( currency = cryptoCurrency, ).fold( ifLeft = { e -> - showErrorDialog(stringReference(e.message.orEmpty())) + dialogFactory.showError(text = stringReference(e.message.orEmpty())) TangemLogger.e("Error: $e") }, ifRight = { @@ -1115,7 +1126,8 @@ internal class TokenDetailsModel @Inject constructor( ifLeft = { e -> when (e) { is AssociateAssetError.NotEnoughBalance -> { - showErrorDialog( + dialogFactory.showError( + text = resourceReference( id = R.string.warning_hedera_token_association_not_enough_hbar_message, formatArgs = wrappedList(e.feeCurrency.symbol), @@ -1123,7 +1135,7 @@ internal class TokenDetailsModel @Inject constructor( ) } is AssociateAssetError.DataError -> { - showErrorDialog(stringReference(e.message.orEmpty())) + dialogFactory.showError(text = stringReference(e.message.orEmpty())) TangemLogger.e("Error: $e") } } @@ -1138,7 +1150,7 @@ internal class TokenDetailsModel @Inject constructor( } override fun onConfirmDisposeExpressStatus() { - showConfirmHideExpressStatusDialog() + dialogFactory.showConfirmHideExpressStatus(onConfirm = ::onDisposeExpressStatus) } override fun onDisposeExpressStatus() { @@ -1172,7 +1184,7 @@ internal class TokenDetailsModel @Inject constructor( private fun handleUnavailabilityReason(unavailabilityReason: ScenarioUnavailabilityReason): Boolean { if (unavailabilityReason == ScenarioUnavailabilityReason.None) return false - showErrorDialog(unavailabilityReason.getUnavailabilityReasonText()) + dialogFactory.showError(text = unavailabilityReason.getUnavailabilityReasonText()) return true } @@ -1201,42 +1213,6 @@ internal class TokenDetailsModel @Inject constructor( uiMessageSender.send(SnackbarMessage(resourceReference(R.string.staking_error_no_validators_title))) } - private fun showConfirmHideTokenDialog(currency: CryptoCurrency) { - dialogFactory.showConfirmHideToken(currency = currency, onConfirm = ::onHideConfirmed) - } - - private fun showLinkedTokensDialog(currency: CryptoCurrency) { - dialogFactory.showLinkedTokens(currency = currency) - } - - private fun showDismissIncompleteTransactionConfirmDialog() { - dialogFactory.showDismissIncompleteTransactionConfirm( - onConfirm = ::onConfirmDismissIncompleteTransactionClick, - ) - } - - private fun showConfirmHideExpressStatusDialog() { - uiMessageSender.send( - DialogMessage( - title = resourceReference(R.string.express_status_hide_dialog_title), - message = resourceReference(R.string.express_status_hide_dialog_text), - firstActionBuilder = { - EventMessageAction( - title = resourceReference(R.string.common_hide), - onClick = { - onDisposeExpressStatus() - }, - ) - }, - secondActionBuilder = { cancelAction() }, - ), - ) - } - - private fun showErrorDialog(text: TextReference) { - dialogFactory.showError(text = text) - } - private fun checkForActionUpdates() { combine( tokenDetailsDeepLinkActionListener.tokenDetailsActionFlow, @@ -1424,6 +1400,50 @@ internal class TokenDetailsModel @Inject constructor( observeRedesignBalance() updateRedesignTopBarMenu() observeRedesignTopBarTitle() + observeRedesignStakingNotification() + } + + private fun observeRedesignStakingNotification() { + val statusFlow = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency) + .map { it.status } + .distinctUntilChanged() + + val availabilityFlow = getStakingAvailabilityUseCase( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) + .map { it.getOrElse { StakingAvailability.Unavailable } } + .distinctUntilChanged() + + val entryInfoFlow = availabilityFlow.mapLatest { availability -> + (availability as? StakingAvailability.Available)?.let { available -> + getStakingEntryInfoUseCase( + cryptoCurrencyId = cryptoCurrency.id, + symbol = cryptoCurrency.symbol, + stakingOption = available.option, + ).getOrNull() + } + } + + combine( + flow = statusFlow, + flow2 = availabilityFlow, + flow3 = entryInfoFlow, + flow4 = selectedAppCurrencyFlow, + ) { status, availability, entryInfo, appCurrency -> + redesignStateController.update( + UpdateStakingNotificationTransformer( + cryptoCurrencyStatus = status, + stakingAvailability = availability, + stakingEntryInfo = entryInfo, + appCurrency = appCurrency, + clickIntents = this, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + .saveIn(redesignEarnJobHolder) } private fun initRedesignState() { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt index 7fcf3fadd1..6175a1fdd6 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsStateController.kt @@ -61,8 +61,9 @@ internal class TokenDetailsStateController @Inject constructor() { tokenBalanceTypeUM = TokenBalanceTypeUM.Single, currencyIconState = CurrencyIconState.Loading, ), + notifications = persistentListOf(), + earnBlockState = null, marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = ""), - stakingBlocksState = null, pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, onRefresh = {}, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt index 91ee9dc832..f67bd6b402 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenDetailsUM.kt @@ -5,7 +5,9 @@ import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.common.ui.earn.EarnBlockUM import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.message.TangemMessageUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @@ -13,8 +15,9 @@ import kotlinx.collections.immutable.ImmutableList internal data class TokenDetailsUM( val topAppBarUM: TokenDetailsTopAppBarUM, val balanceBlockUM: TokenDetailsBalanceBlockUM, + val notifications: ImmutableList, + val earnBlockState: EarnBlockUM?, val marketPriceBlockState: MarketPriceBlockState, - val stakingBlocksState: StakingBlockUM?, val pullToRefreshConfig: PullToRefreshConfig, val isBalanceHidden: Boolean, val isMarketPriceAvailable: Boolean, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt new file mode 100644 index 0000000000..a8ecb28437 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt @@ -0,0 +1,225 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.message.TangemMessageButtonUM +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.message.TangemMessageUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.tokens.model.warnings.HederaWarnings +import com.tangem.domain.tokens.model.warnings.KaspaWarnings +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import com.tangem.core.res.R as CoreResR + +internal class UpdateNotificationsTransformer( + private val warnings: Set, + private val clickIntents: TokenDetailsClickIntents, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + val notifications = warnings.mapNotNull(::mapWarning).toImmutableList() + return prevState.copy(notifications = notifications) + } + + @Suppress("LongMethod") + private fun mapWarning(warning: CryptoCurrencyWarning): TangemMessageUM? { + return when (warning) { + is CryptoCurrencyWarning.SomeNetworksUnreachable -> TangemMessageUM( + id = "networks_unreachable", + title = resourceReference(CoreResR.string.warning_network_unreachable_title), + subtitle = resourceReference(CoreResR.string.warning_network_unreachable_message), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + ) + is CryptoCurrencyWarning.BalanceNotEnoughForFee -> createFeeWarning( + FeeWarningParams( + id = "balance_not_enough_for_fee", + currency = warning.tokenCurrency, + networkName = warning.coinCurrency.network.name, + feeCurrencyName = warning.coinCurrency.name, + feeCurrencySymbol = warning.coinCurrency.symbol, + buyCurrency = warning.coinCurrency, + ), + ) + is CryptoCurrencyWarning.CustomTokenNotEnoughForFee -> createFeeWarning( + FeeWarningParams( + id = "custom_token_not_enough_for_fee", + currency = warning.currency, + networkName = warning.feeCurrency?.network?.name ?: warning.networkName, + feeCurrencyName = warning.feeCurrencyName, + feeCurrencySymbol = warning.feeCurrencySymbol, + buyCurrency = warning.feeCurrency, + ), + ) + is CryptoCurrencyWarning.BeaconChainShutdown -> TangemMessageUM( + id = "beacon_chain_shutdown", + title = resourceReference(CoreResR.string.warning_beacon_chain_retirement_title), + subtitle = resourceReference(CoreResR.string.warning_beacon_chain_retirement_content), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + ) + is HederaWarnings.AssociateWarning -> TangemMessageUM( + id = "hedera_associate", + title = resourceReference(CoreResR.string.warning_hedera_missing_token_association_title), + subtitle = resourceReference(CoreResR.string.warning_hedera_missing_token_association_message_brief), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_hedera_missing_token_association_button_title), + type = TangemButtonType.Primary, + onClick = clickIntents::onAssociateClick, + ), + ), + ) + is HederaWarnings.AssociateWarningWithFee -> TangemMessageUM( + id = "hedera_associate_fee", + title = resourceReference(CoreResR.string.warning_hedera_missing_token_association_title), + subtitle = resourceReference( + CoreResR.string.warning_hedera_missing_token_association_message, + wrappedList( + warning.fee.format { crypto(symbol = "", decimals = warning.feeCurrencyDecimals) }, + warning.feeCurrencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_hedera_missing_token_association_button_title), + type = TangemButtonType.Primary, + onClick = clickIntents::onAssociateClick, + ), + ), + ) + is CryptoCurrencyWarning.RequiredTrustline -> TangemMessageUM( + id = "required_trustline", + title = resourceReference(CoreResR.string.warning_token_trustline_title), + subtitle = resourceReference( + CoreResR.string.warning_token_trustline_subtitle, + wrappedList( + warning.requiredAmount.format { crypto(symbol = "", warning.currencyDecimals) }.trim(), + warning.currencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_token_trustline_button_title), + type = TangemButtonType.Primary, + onClick = clickIntents::onOpenTrustlineClick, + ), + ), + ) + is KaspaWarnings.IncompleteTransaction -> TangemMessageUM( + id = "kaspa_incomplete", + title = resourceReference(CoreResR.string.warning_kaspa_unfinished_token_transaction_title), + subtitle = resourceReference( + CoreResR.string.warning_kaspa_unfinished_token_transaction_message, + wrappedList( + warning.amount.format { crypto(symbol = "", decimals = warning.currencyDecimals) }, + warning.currencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.alert_button_try_again), + type = TangemButtonType.Primary, + onClick = clickIntents::onRetryIncompleteTransactionClick, + ), + ), + onCloseClick = clickIntents::onDismissIncompleteTransactionClick, + ) + is CryptoCurrencyWarning.MigrationMaticToPol -> TangemMessageUM( + id = "migration_matic_pol", + title = resourceReference(CoreResR.string.warning_matic_migration_title), + subtitle = resourceReference(CoreResR.string.warning_matic_migration_message), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + ) + is CryptoCurrencyWarning.MigrationClore -> TangemMessageUM( + id = "migration_clore", + title = resourceReference(CoreResR.string.warning_clore_migration_title), + subtitle = resourceReference(CoreResR.string.warning_clore_migration_description), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.warning_clore_migration_button), + type = TangemButtonType.Primary, + onClick = clickIntents::onCloreMigrationClick, + ), + ), + ) + // Non-warning types — skip for redesign + is CryptoCurrencyWarning.ExistentialDeposit, + is CryptoCurrencyWarning.Rent, + is CryptoCurrencyWarning.SomeNetworksNoAccount, + is CryptoCurrencyWarning.TopUpWithoutReserve, + is CryptoCurrencyWarning.SwapPromo, + is CryptoCurrencyWarning.FeeResourceInfo, + is CryptoCurrencyWarning.UsedOutdatedDataWarning, + -> null + } + } + + private fun createFeeWarning(params: FeeWarningParams): TangemMessageUM { + val buttons = if (params.buyCurrency != null) { + persistentListOf( + TangemMessageButtonUM( + text = resourceReference( + CoreResR.string.common_buy_currency, + wrappedList(params.feeCurrencySymbol), + ), + type = TangemButtonType.Primary, + onClick = { clickIntents.onBuyCoinClick(params.buyCurrency) }, + ), + ) + } else { + persistentListOf() + } + + return TangemMessageUM( + id = params.id, + title = resourceReference( + CoreResR.string.warning_send_blocked_funds_for_fee_title, + wrappedList(params.feeCurrencyName), + ), + subtitle = resourceReference( + CoreResR.string.warning_send_blocked_funds_for_fee_message, + wrappedList( + params.currency.name, + params.networkName, + params.currency.name, + params.feeCurrencyName, + params.feeCurrencySymbol, + ), + ), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = buttons, + ) + } + + private data class FeeWarningParams( + val id: String, + val currency: CryptoCurrency, + val networkName: String, + val feeCurrencyName: String, + val feeCurrencySymbol: String, + val buyCurrency: CryptoCurrency?, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt new file mode 100644 index 0000000000..abb2d6d3d3 --- /dev/null +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt @@ -0,0 +1,298 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import androidx.compose.ui.text.SpanStyle +import com.tangem.common.getRewardStakingBalance +import com.tangem.common.getTotalStakingBalance +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.defaultAmount +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.formatStyled +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.staking.RewardBlockType +import com.tangem.domain.models.staking.StakingBalance +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.StakingOption +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import com.tangem.features.tokendetails.impl.R +import com.tangem.lib.crypto.BlockchainUtils.isStakingRewardUnavailable +import com.tangem.utils.isNullOrZero +import com.tangem.utils.transformer.Transformer +import java.math.BigDecimal +import com.tangem.core.res.R as CoreResR +import com.tangem.core.ui.R as CoreUiR + +internal class UpdateStakingNotificationTransformer( + private val cryptoCurrencyStatus: CryptoCurrencyStatus, + private val stakingAvailability: StakingAvailability, + private val stakingEntryInfo: StakingEntryInfo?, + private val appCurrency: AppCurrency, + private val clickIntents: TokenDetailsClickIntents, +) : Transformer { + + override fun transform(prevState: TokenDetailsUM): TokenDetailsUM { + return prevState.copy(earnBlockState = buildEarnBlock(prevState.isBalanceHidden)) + } + + private fun buildEarnBlock(isBalanceHidden: Boolean): EarnBlockUM? { + return when (val availability = stakingAvailability) { + StakingAvailability.TemporaryUnavailable -> buildTemporaryUnavailable() + StakingAvailability.Unavailable -> null + is StakingAvailability.Available -> getStakingInfoBlock(availability, isBalanceHidden) + } + } + + private fun buildTemporaryUnavailable(): EarnBlockUM.Content { + return EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.staking_native), + style = EarnBlockUM.TitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.tertiary }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.staking_notification_network_error_text), + style = EarnBlockUM.SubtitleUM.Style.Small, + color = { TangemTheme.colors2.text.neutral.tertiary }, + ), + trailingUM = null, + ) + } + + private fun getStakingInfoBlock( + availability: StakingAvailability.Available, + isBalanceHidden: Boolean, + ): EarnBlockUM? { + val status = cryptoCurrencyStatus + val stakingBalance = status.value.stakingBalance as? StakingBalance.Data + val stakingCryptoAmount = stakingBalance?.getTotalStakingBalance(status.currency.network.rawId) + + return when { + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo != null -> { + val hasPendingBalances = stakingBalance.hasPendingBalances() + if (!hasPendingBalances) { + buildStakeAvailable( + availability = availability, + isEnabled = isStakingButtonEnabled(status), + ) + } else { + buildActiveBlock( + stakingAmount = stakingBalance.getPendingAmount(), + rewardAmount = null, + isBalanceHidden = isBalanceHidden, + ) + } + } + stakingCryptoAmount.isNullOrZero() && stakingEntryInfo == null -> null + else -> buildActiveBlock( + stakingAmount = stakingCryptoAmount, + rewardAmount = stakingBalance.getRewardAmount(), + isBalanceHidden = isBalanceHidden, + ) + } + } + + private fun isStakingButtonEnabled(status: CryptoCurrencyStatus): Boolean { + return status.value is CryptoCurrencyStatus.Loaded || + status.value is CryptoCurrencyStatus.NoQuote || + status.value is CryptoCurrencyStatus.Custom + } + + private fun buildStakeAvailable( + availability: StakingAvailability.Available, + isEnabled: Boolean, + ): EarnBlockUM.Content { + return EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Tinted { TangemTheme.colors2.markers.backgroundTintedBlue }, + iconUM = EarnBlockUM.IconUM.Glowing( + iconRes = CoreUiR.drawable.ic_staking_40, + glowColor = { TangemTheme.colors2.border.status.accent }, + ), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(id = R.string.token_details_staking_block_title), + style = EarnBlockUM.TitleUM.Style.Small, + color = { TangemTheme.colors2.text.status.accent }, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = stakeAvailableSubtitle(availability.option.displayApy), + style = EarnBlockUM.SubtitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.primary }, + ), + trailingUM = EarnBlockUM.TrailingUM.Button( + buttonUM = TangemButtonUM( + text = resourceReference(R.string.common_stake), + type = TangemButtonType.Accent, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + isEnabled = isEnabled, + onClick = clickIntents::onStakeBannerClick, + ), + ), + ) + } + + private fun stakeAvailableSubtitle(apy: BigDecimal?): TextReference { + return if (apy != null) { + resourceReference( + CoreResR.string.token_details_earn_staking_subtitle, + wrappedList(apy.format { percent() }), + ) + } else { + resourceReference(CoreResR.string.staking_notification_earn_rewards_text) + } + } + + private fun buildActiveBlock( + stakingAmount: BigDecimal?, + rewardAmount: BigDecimal?, + isBalanceHidden: Boolean, + ): EarnBlockUM.Content { + val status = cryptoCurrencyStatus + val fiatRate = status.value.fiatRate + val fiatAmount = stakingAmount?.let { fiatRate?.multiply(it) } + val rewardFiatAmount = rewardAmount?.let { fiatRate?.multiply(it) } + return EarnBlockUM.Content( + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing( + iconRes = CoreUiR.drawable.ic_staking_40, + glowColor = { TangemTheme.colors2.border.status.accent }, + ), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.staking_native), + style = EarnBlockUM.TitleUM.Style.Large, + color = { TangemTheme.colors2.text.neutral.primary }, + ), + subtitleUM = getRewardSubtitle(status, rewardFiatAmount), + trailingUM = EarnBlockUM.TrailingUM.Balance( + fiatValue = fiatAmount.formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).defaultAmount( + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ) + }, + cryptoValue = stringReference( + stakingAmount.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }, + ), + isBalanceHidden = isBalanceHidden, + onClick = clickIntents::onStakeBannerClick, + ), + ) + } + + private fun getRewardSubtitle( + status: CryptoCurrencyStatus, + stakingRewardAmount: BigDecimal?, + ): EarnBlockUM.SubtitleUM? { + val blockchainId = status.currency.network.rawId + val isCoin = status.currency.id.isCoin + val stakingBalance = status.value.stakingBalance + + val rewardBlockType = when { + stakingBalance is StakingBalance.Data.P2PEthPool -> { + if (stakingBalance.totalRewards.isNullOrZero()) { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } else { + RewardBlockType.EthereumEarnedRewards + } + } + isStakingRewardUnavailable(blockchainId, isCoin) -> { + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable + } + stakingRewardAmount.isNullOrZero() -> RewardBlockType.NoRewards + else -> RewardBlockType.Rewards + } + + val text = when (rewardBlockType) { + RewardBlockType.NoRewards -> resourceReference(R.string.staking_details_no_rewards_to_claim) + RewardBlockType.CardanoNoRewards -> resourceReference(R.string.staking_cardano_details_rewards_info_text) + RewardBlockType.RewardUnavailable.DefaultRewardUnavailable, + RewardBlockType.RewardUnavailable.SolanaRewardUnavailable, + -> return null + RewardBlockType.EthereumEarnedRewards -> { + val cryptoRewardAmount = (stakingBalance as? StakingBalance.Data.P2PEthPool)?.totalRewards + resourceReference( + R.string.staking_details_autocompound_rewards_earned, + wrappedList( + cryptoRewardAmount.format { + crypto( + symbol = status.currency.symbol, + decimals = status.currency.decimals, + ) + }, + ), + ) + } + RewardBlockType.RewardsRequirementsError, + RewardBlockType.Rewards, + -> resourceReference( + R.string.staking_details_rewards_to_claim, + wrappedList( + stakingRewardAmount.format { fiat(appCurrency.code, appCurrency.symbol) }, + ), + ) + } + + val isAccent = rewardBlockType == RewardBlockType.Rewards || + rewardBlockType == RewardBlockType.RewardsRequirementsError || + rewardBlockType == RewardBlockType.EthereumEarnedRewards + + return EarnBlockUM.SubtitleUM.Text( + text = text, + style = EarnBlockUM.SubtitleUM.Style.Small, + color = if (isAccent) { + { TangemTheme.colors2.text.status.accent } + } else { + { TangemTheme.colors2.text.neutral.tertiary } + }, + ) + } +} + +private fun StakingBalance.Data?.hasPendingBalances(): Boolean = when (this) { + is StakingBalance.Data.StakeKit -> balance.items.isNotEmpty() + is StakingBalance.Data.P2PEthPool -> !unstakingAmount.isNullOrZero() + null -> false +} + +private fun StakingBalance.Data?.getPendingAmount(): BigDecimal = when (this) { + is StakingBalance.Data.StakeKit -> balance.items.sumOf { it.amount } + is StakingBalance.Data.P2PEthPool -> unstakingAmount + null -> BigDecimal.ZERO +} + +private fun StakingBalance.Data?.getRewardAmount(): BigDecimal = when (this) { + is StakingBalance.Data.StakeKit -> getRewardStakingBalance() + is StakingBalance.Data.P2PEthPool -> totalRewards + null -> BigDecimal.ZERO +} + +private val StakingOption.displayApy: BigDecimal? + get() = when (this) { + is StakingOption.StakeKit -> yield.preferredValidators + .mapNotNull { it.rewardInfo?.rate } + .maxOrNull() + is StakingOption.P2PEthPool -> apy + } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 25f0277b18..14147b34e3 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -10,10 +10,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn +import com.tangem.common.ui.earn.EarnBlock +import com.tangem.common.ui.notifications.notifications import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity @@ -59,6 +62,11 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi partialCollapsedHeight = partialCollapsedHeight, ) + val rootBackground by LocalRootBackgroundColor.current + val notificationModifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + Box( modifier = modifier.fillMaxSize(), ) { @@ -79,18 +87,18 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi ) }, body = { - LazyColumn( + TokenDetailsBody( + tokenDetailsUM = tokenDetailsUM, + rootBackground = rootBackground, modifier = Modifier .fillMaxSize() .nestedScroll(behavior.nestedScrollConnection), - ) { - // TODO [REDACTED_TASK_KEY] Token Details Make Transaction History - } + itemModifier = notificationModifier, + ) }, ) } - val rootBackground by LocalRootBackgroundColor.current val hazeIntensity by animateFloatAsState( targetValue = (behavior.state.collapsedFraction * 2f).coerceIn(0f, 1f), label = "TopBarHazeIntensity", @@ -110,6 +118,31 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi } } +@Composable +private fun TokenDetailsBody( + tokenDetailsUM: TokenDetailsUM, + rootBackground: Color, + modifier: Modifier = Modifier, + itemModifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + notifications( + notifications = tokenDetailsUM.notifications, + contentColor = rootBackground, + modifier = itemModifier, + ) + tokenDetailsUM.earnBlockState?.let { earnBlock -> + item(key = "staking_block") { + EarnBlock( + state = earnBlock, + modifier = itemModifier, + ) + } + } + // TODO [REDACTED_TASK_KEY] Token Details Make Transaction History + } +} + // region Preview @Composable @Preview(showBackground = true, widthDp = 360) @@ -137,13 +170,14 @@ private fun TokenDetailsScreen_Preview() { ), ), ), + notifications = persistentListOf(), + earnBlockState = null, balanceBlockUM = TokenDetailsBalanceBlockUM.Loading( actionButtons = persistentListOf(), tokenBalanceTypeUM = TokenBalanceTypeUM.Single, currencyIconState = CurrencyIconState.Loading, ), marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = "USDT"), - stakingBlocksState = null, pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, onRefresh = {}, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index 21fdf29fd8..4a1b464543 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -31,7 +31,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.T import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsTopAppBar import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenInfoBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.ExpressStatusBottomSheet -import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlock +import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.staking.TokenStakingBlockLegacy import com.tangem.features.markets.token.block.TokenMarketBlockComponent import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.entity.TxHistoryUM @@ -133,7 +133,7 @@ internal fun TokenDetailsScreenLegacy( key = StakingBlockUM::class.java, contentType = StakingBlockUM::class.java, content = { - TokenStakingBlock( + TokenStakingBlockLegacy( state = state.stakingBlocksState, isBalanceHidden = state.isBalanceHidden, modifier = itemModifier, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlockLegacy.kt similarity index 95% rename from features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt rename to features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlockLegacy.kt index 39bfbef5ec..4b78546b82 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/staking/TokenStakingBlockLegacy.kt @@ -42,7 +42,7 @@ import com.tangem.features.tokendetails.impl.R * @param modifier modifier */ @Composable -internal fun TokenStakingBlock(state: StakingBlockUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { +internal fun TokenStakingBlockLegacy(state: StakingBlockUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { Column( modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) @@ -60,16 +60,16 @@ internal fun TokenStakingBlock(state: StakingBlockUM, isBalanceHidden: Boolean, targetState = state, contentAlignment = Alignment.CenterStart, label = "Staking block animation", - ) { - when (it) { + ) { stakingBlock -> + when (stakingBlock) { is StakingBlockUM.TemporaryUnavailable -> StakingTemporaryUnavailableBlock() is StakingBlockUM.Loading -> StakingLoading() is StakingBlockUM.Staked -> StakingBalanceBlock( - state = it, + state = stakingBlock, isBalanceHidden = isBalanceHidden, ) is StakingBlockUM.StakeAvailable -> StakingAvailableContent( - state = it, + state = stakingBlock, ) } } @@ -165,7 +165,7 @@ private fun Preview_TokenStakingBlock( state: StakingBlockUM, ) { TangemThemePreview { - TokenStakingBlock( + TokenStakingBlockLegacy( state = state, isBalanceHidden = false, ) From fc6767346cb7fb44ff3722a13e8e64fffebc4cdd Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Apr 2026 11:50:56 +0300 Subject: [PATCH 069/206] Updated on 2026-08-14 --- ...ializeWithCryptoCurrencyTransformerTest.kt | 5 +- .../SetBalanceLoadingTransformerTest.kt | 5 +- .../transformer/SetBalanceTransformerTest.kt | 5 +- .../SetTopBarTitleTransformerTest.kt | 3 +- .../ToggleBalanceTypeTransformerTest.kt | 5 +- .../UpdateNotificationsTransformerTest.kt | 540 ++++++++++++++++++ ...pdateStakingNotificationTransformerTest.kt | 137 +++++ .../UpdateTopBarMenuTransformerTest.kt | 3 +- 8 files changed, 693 insertions(+), 10 deletions(-) create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt index 8c0623c3e8..52acfdab97 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformerTest.kt @@ -102,7 +102,7 @@ class InitializeWithCryptoCurrencyTransformerTest { assertThat(result.topAppBarUM.menuItems).isEqualTo(state.topAppBarUM.menuItems) assertThat(result.balanceBlockUM.actionButtons).isEqualTo(state.balanceBlockUM.actionButtons) assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(state.balanceBlockUM.tokenBalanceTypeUM) - assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState) + assertThat(result.earnBlockState).isEqualTo(state.earnBlockState) assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) @@ -120,8 +120,9 @@ class InitializeWithCryptoCurrencyTransformerTest { tokenBalanceTypeUM = TokenBalanceTypeUM.Single, currencyIconState = mockk(relaxed = true), ), + notifications = persistentListOf(), marketPriceBlockState = mockk(relaxed = true), - stakingBlocksState = null, + earnBlockState = null, pullToRefreshConfig = mockk(relaxed = true), isBalanceHidden = false, isMarketPriceAvailable = false, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt index 1d7b67394f..8e8a26f034 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceLoadingTransformerTest.kt @@ -114,7 +114,7 @@ class SetBalanceLoadingTransformerTest { // THEN assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) - assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState) + assertThat(result.earnBlockState).isEqualTo(state.earnBlockState) assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) @@ -134,8 +134,9 @@ class SetBalanceLoadingTransformerTest { tokenBalanceTypeUM = TokenBalanceTypeUM.Single, currencyIconState = CurrencyIconState.Loading, ), + notifications = persistentListOf(), marketPriceBlockState = mockk(relaxed = true), - stakingBlocksState = null, + earnBlockState = null, pullToRefreshConfig = mockk(relaxed = true), isBalanceHidden = false, isMarketPriceAvailable = false, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt index 27a1733679..a78c3ed430 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetBalanceTransformerTest.kt @@ -390,7 +390,7 @@ class SetBalanceTransformerTest { // THEN assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) - assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState) + assertThat(result.earnBlockState).isEqualTo(state.earnBlockState) assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) @@ -472,8 +472,9 @@ class SetBalanceTransformerTest { tokenBalanceTypeUM = TokenBalanceTypeUM.Single, currencyIconState = CurrencyIconState.Loading, ), + notifications = persistentListOf(), marketPriceBlockState = mockk(relaxed = true), - stakingBlocksState = null, + earnBlockState = null, pullToRefreshConfig = mockk(relaxed = true), isBalanceHidden = false, isMarketPriceAvailable = false, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt index 7f72295f3a..2e1d75e21c 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/SetTopBarTitleTransformerTest.kt @@ -195,8 +195,9 @@ class SetTopBarTitleTransformerTest { menuItems = persistentListOf(), ), balanceBlockUM = mockk(relaxed = true), + notifications = persistentListOf(), marketPriceBlockState = mockk(relaxed = true), - stakingBlocksState = null, + earnBlockState = null, pullToRefreshConfig = mockk(relaxed = true), isBalanceHidden = false, isMarketPriceAvailable = false, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt index 2a288fa164..99be5d0fa3 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/ToggleBalanceTypeTransformerTest.kt @@ -134,7 +134,7 @@ class ToggleBalanceTypeTransformerTest { // THEN assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) - assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState) + assertThat(result.earnBlockState).isEqualTo(state.earnBlockState) assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) @@ -194,8 +194,9 @@ class ToggleBalanceTypeTransformerTest { tokenBalanceTypeUM = TokenBalanceTypeUM.Single, currencyIconState = CurrencyIconState.Loading, ), + notifications = persistentListOf(), marketPriceBlockState = mockk(relaxed = true), - stakingBlocksState = null, + earnBlockState = null, pullToRefreshConfig = mockk(relaxed = true), isBalanceHidden = false, isMarketPriceAvailable = false, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt new file mode 100644 index 0000000000..3cd6df9608 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt @@ -0,0 +1,540 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.tokens.model.warnings.HederaWarnings +import com.tangem.domain.tokens.model.warnings.KaspaWarnings +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class UpdateNotificationsTransformerTest { + + private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true) + + // region Mapping warnings to notifications + + @Test + fun `GIVEN empty warnings WHEN transform THEN notifications are empty`() { + // GIVEN + val transformer = createTransformer(warnings = emptySet()) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN SomeNetworksUnreachable WHEN transform THEN notification with id networks_unreachable is created`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.SomeNetworksUnreachable), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("networks_unreachable") + } + + @Test + fun `GIVEN BalanceNotEnoughForFee WHEN transform THEN notification with id balance_not_enough_for_fee is created`() { + // GIVEN + val tokenCurrency: CryptoCurrency = mockk(relaxed = true) + val coinCurrency: CryptoCurrency = mockk(relaxed = true) { + io.mockk.every { network } returns mockk(relaxed = true) { + io.mockk.every { name } returns "Ethereum" + } + io.mockk.every { name } returns "Ethereum" + io.mockk.every { symbol } returns "ETH" + } + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.BalanceNotEnoughForFee( + tokenCurrency = tokenCurrency, + coinCurrency = coinCurrency, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("balance_not_enough_for_fee") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN CustomTokenNotEnoughForFee with null feeCurrency WHEN transform THEN notification has no buttons`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.CustomTokenNotEnoughForFee( + currency = currency, + feeCurrency = null, + networkName = "Ethereum", + feeCurrencyName = "Ethereum", + feeCurrencySymbol = "ETH", + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("custom_token_not_enough_for_fee") + assertThat(result.notifications.first().buttonsUM).isEmpty() + } + + @Test + fun `GIVEN BeaconChainShutdown WHEN transform THEN notification with id beacon_chain_shutdown is created`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.BeaconChainShutdown), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("beacon_chain_shutdown") + } + + @Test + fun `GIVEN MigrationMaticToPol WHEN transform THEN notification with id migration_matic_pol is created`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.MigrationMaticToPol), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("migration_matic_pol") + } + + @Test + fun `GIVEN MigrationClore WHEN transform THEN notification has button and id migration_clore`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.MigrationClore), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("migration_clore") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN HederaAssociateWarning WHEN transform THEN notification with button is created`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf(HederaWarnings.AssociateWarning(currency = currency)), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("hedera_associate") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN HederaAssociateWarningWithFee WHEN transform THEN notification with button is created`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf( + HederaWarnings.AssociateWarningWithFee( + currency = currency, + fee = BigDecimal("0.05"), + feeCurrencySymbol = "HBAR", + feeCurrencyDecimals = 8, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("hedera_associate_fee") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN RequiredTrustline WHEN transform THEN notification with button is created`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.RequiredTrustline( + currency = currency, + currencySymbol = "XLM", + requiredAmount = BigDecimal("10"), + currencyDecimals = 7, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("required_trustline") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN KaspaIncompleteTransaction WHEN transform THEN notification with button and close is created`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf( + KaspaWarnings.IncompleteTransaction( + currency = currency, + amount = BigDecimal("100"), + currencySymbol = "KAS", + currencyDecimals = 8, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("kaspa_incomplete") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + assertThat(result.notifications.first().onCloseClick).isNotNull() + } + + // endregion + + // region Skipped warnings + + @Test + fun `GIVEN ExistentialDeposit WHEN transform THEN notification is skipped`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.ExistentialDeposit( + currencyName = "Polkadot", + edStringValueWithSymbol = "1 DOT", + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN Rent WHEN transform THEN notification is skipped`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.Rent( + rent = BigDecimal("0.00001"), + exemptionAmount = BigDecimal("0.01"), + cryptoCurrency = mockk(relaxed = true), + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN UsedOutdatedDataWarning WHEN transform THEN notification is skipped`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.UsedOutdatedDataWarning), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).isEmpty() + } + + // endregion + + // region Message effect + + @Test + fun `GIVEN any mapped warning WHEN transform THEN messageEffect is None`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.SomeNetworksUnreachable), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications.first().messageEffect).isEqualTo(TangemMessageEffect.None) + } + + // endregion + + // region Icon + + @Test + fun `GIVEN any mapped warning WHEN transform THEN iconUM is not null`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.BeaconChainShutdown), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications.first().iconUM).isNotNull() + } + + // endregion + + // region Multiple warnings + + @Test + fun `GIVEN multiple warnings with some skipped WHEN transform THEN only mapped warnings are in notifications`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.SomeNetworksUnreachable, + CryptoCurrencyWarning.BeaconChainShutdown, + CryptoCurrencyWarning.UsedOutdatedDataWarning, + CryptoCurrencyWarning.TopUpWithoutReserve, + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(2) + assertThat(result.notifications.map { it.id }).containsExactly( + "networks_unreachable", + "beacon_chain_shutdown", + ) + } + + // endregion + + // region Click callbacks + + @Test + fun `GIVEN HederaAssociateWarning WHEN button clicked THEN onAssociateClick is called`() { + // GIVEN + val currency: CryptoCurrency = mockk(relaxed = true) + val transformer = createTransformer( + warnings = setOf(HederaWarnings.AssociateWarning(currency = currency)), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onAssociateClick() } + } + + @Test + fun `GIVEN RequiredTrustline WHEN button clicked THEN onOpenTrustlineClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.RequiredTrustline( + currency = mockk(relaxed = true), + currencySymbol = "XLM", + requiredAmount = BigDecimal("10"), + currencyDecimals = 7, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onOpenTrustlineClick() } + } + + @Test + fun `GIVEN KaspaIncompleteTransaction WHEN retry clicked THEN onRetryIncompleteTransactionClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + KaspaWarnings.IncompleteTransaction( + currency = mockk(relaxed = true), + amount = BigDecimal("100"), + currencySymbol = "KAS", + currencyDecimals = 8, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onRetryIncompleteTransactionClick() } + } + + @Test + fun `GIVEN KaspaIncompleteTransaction WHEN close clicked THEN onDismissIncompleteTransactionClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf( + KaspaWarnings.IncompleteTransaction( + currency = mockk(relaxed = true), + amount = BigDecimal("100"), + currencySymbol = "KAS", + currencyDecimals = 8, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().onCloseClick!!.invoke() + + // THEN + verify(exactly = 1) { clickIntents.onDismissIncompleteTransactionClick() } + } + + @Test + fun `GIVEN MigrationClore WHEN button clicked THEN onCloreMigrationClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.MigrationClore), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onCloreMigrationClick() } + } + + @Test + fun `GIVEN BalanceNotEnoughForFee WHEN buy button clicked THEN onBuyCoinClick is called`() { + // GIVEN + val coinCurrency: CryptoCurrency = mockk(relaxed = true) { + io.mockk.every { network } returns mockk(relaxed = true) { + io.mockk.every { name } returns "Ethereum" + } + io.mockk.every { name } returns "Ethereum" + io.mockk.every { symbol } returns "ETH" + } + val transformer = createTransformer( + warnings = setOf( + CryptoCurrencyWarning.BalanceNotEnoughForFee( + tokenCurrency = mockk(relaxed = true), + coinCurrency = coinCurrency, + ), + ), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onBuyCoinClick(coinCurrency) } + } + + // endregion + + // region State preservation + + @Test + fun `GIVEN any warnings WHEN transform THEN unrelated state fields are preserved`() { + // GIVEN + val state = initialState() + val transformer = createTransformer( + warnings = setOf(CryptoCurrencyWarning.SomeNetworksUnreachable), + ) + + // WHEN + val result = transformer.transform(state) + + // THEN + assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM) + assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM) + assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState) + assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig) + assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden) + assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable) + } + + // endregion + + private fun createTransformer(warnings: Set) = UpdateNotificationsTransformer( + warnings = warnings, + clickIntents = clickIntents, + ) + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = "Tether"), + subtitle = stringReference("ERC-20 in Ethereum network"), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = mockk(relaxed = true), + notifications = persistentListOf(), + marketPriceBlockState = mockk(relaxed = true), + earnBlockState = null, + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt new file mode 100644 index 0000000000..cc0a370391 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformerTest.kt @@ -0,0 +1,137 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.components.marketprice.MarketPriceBlockState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.appcurrency.model.AppCurrency +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.staking.StakingBalance +import com.tangem.domain.staking.model.StakingAvailability +import com.tangem.domain.staking.model.StakingEntryInfo +import com.tangem.domain.staking.model.StakingOption +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState +import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class UpdateStakingNotificationTransformerTest { + + private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true) + + @Test + fun `GIVEN Unavailable WHEN transform THEN earnBlockState is null`() { + val transformer = createTransformer( + availability = StakingAvailability.Unavailable, + entryInfo = null, + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isNull() + } + + @Test + fun `GIVEN TemporaryUnavailable WHEN transform THEN TemporaryUnavailable`() { + val transformer = createTransformer( + availability = StakingAvailability.TemporaryUnavailable, + entryInfo = null, + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isInstanceOf(EarnBlockUM.Content::class.java) + val content = result.earnBlockState as EarnBlockUM.Content + assertThat(content.iconUM).isInstanceOf(EarnBlockUM.IconUM.Plain::class.java) + assertThat(content.trailingUM).isNull() + } + + @Test + fun `GIVEN Available without entryInfo AND no staked WHEN transform THEN null`() { + val transformer = createTransformer( + availability = availableOption(BigDecimal("4.2")), + entryInfo = null, + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isNull() + } + + @Test + fun `GIVEN Available with entryInfo AND no staked WHEN transform THEN Available`() { + val transformer = createTransformer( + availability = availableOption(BigDecimal("4.2")), + entryInfo = StakingEntryInfo(tokenSymbol = "SOL"), + ) + + val result = transformer.transform(initialState()) + + assertThat(result.earnBlockState).isInstanceOf(EarnBlockUM.Content::class.java) + val content = result.earnBlockState as EarnBlockUM.Content + assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java) + } + + private fun createTransformer( + availability: StakingAvailability, + entryInfo: StakingEntryInfo?, + ) = UpdateStakingNotificationTransformer( + cryptoCurrencyStatus = buildStatus(), + stakingAvailability = availability, + stakingEntryInfo = entryInfo, + appCurrency = AppCurrency.Default, + clickIntents = clickIntents, + ) + + private fun buildStatus(): CryptoCurrencyStatus { + val network = mockk(relaxed = true) { + every { rawId } returns "solana" + every { isTestnet } returns false + } + val currency = mockk(relaxed = true) { + every { symbol } returns "SOL" + every { decimals } returns 9 + every { this@mockk.network } returns network + every { id.isCoin } returns true + } + val stakingBalance = mockk(relaxed = true) + val value = mockk(relaxed = true) { + every { this@mockk.stakingBalance } returns stakingBalance + every { fiatRate } returns BigDecimal.ONE + every { yieldSupplyStatus } returns null + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + + private fun availableOption(apy: BigDecimal): StakingAvailability.Available { + val option = mockk(relaxed = true) { + every { this@mockk.apy } returns apy + } + return StakingAvailability.Available(option = option) + } + + private fun initialState(): TokenDetailsUM = TokenDetailsUM( + topAppBarUM = TokenDetailsTopAppBarUM( + titleState = TitleState.Simple(tokenName = "Solana"), + subtitle = stringReference("Solana network"), + onBackClick = {}, + menuItems = persistentListOf(), + ), + balanceBlockUM = mockk(relaxed = true), + notifications = persistentListOf(), + earnBlockState = null, + marketPriceBlockState = mockk(relaxed = true), + pullToRefreshConfig = mockk(relaxed = true), + isBalanceHidden = false, + isMarketPriceAvailable = false, + ) +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt index f5c2d80d1e..f12747803f 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt @@ -205,8 +205,9 @@ class UpdateTopBarMenuTransformerTest { menuItems = persistentListOf(), ), balanceBlockUM = mockk(relaxed = true), + notifications = persistentListOf(), marketPriceBlockState = mockk(relaxed = true), - stakingBlocksState = null, + earnBlockState = null, pullToRefreshConfig = mockk(relaxed = true), isBalanceHidden = false, isMarketPriceAvailable = false, From 462325dce480c451df37e60bfd5e60038fa4d416 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Apr 2026 11:51:16 +0300 Subject: [PATCH 070/206] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 1 + .../src/main/res/drawable/ic_staking_40.xml | 41 ++++++++++++++++++ .../res/drawable/ic_staking_disable_40.xml | 42 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 core/ui/src/main/res/drawable/ic_staking_40.xml create mode 100644 core/ui/src/main/res/drawable/ic_staking_disable_40.xml diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 6321abad93..e316bc77d9 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1720,6 +1720,7 @@ Approval has been revoked. Your funds remain in Yield mode. To perform actions, please go to Yield mode and grant permission again. Available balance Total balance + Earn up to %s a year Generate XPUB Hide You are about to hide this token from the main screen. You can add it back anytime through the manage tokens page. diff --git a/core/ui/src/main/res/drawable/ic_staking_40.xml b/core/ui/src/main/res/drawable/ic_staking_40.xml new file mode 100644 index 0000000000..07c94be806 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_staking_40.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_staking_disable_40.xml b/core/ui/src/main/res/drawable/ic_staking_disable_40.xml new file mode 100644 index 0000000000..e7442dc61c --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_staking_disable_40.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + From 88a08c09f320f288c855cc2c3d756d5521651f52 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 16:38:20 +0500 Subject: [PATCH 071/206] Updated on 2026-08-14 --- .../analytics/TokenReceiveNewAnalyticsEvent.kt | 3 ++- .../domain/tangempay/TangemPayAnalyticsEvents.kt | 15 ++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt index bd66565b40..0b817b3324 100644 --- a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/analytics/TokenReceiveNewAnalyticsEvent.kt @@ -6,6 +6,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN import com.tangem.core.analytics.models.AnalyticsParam.Key.ENS import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM +import com.tangem.core.analytics.models.AppsFlyerIncludedEvent sealed class TokenReceiveNewAnalyticsEvent( event: String, @@ -36,7 +37,7 @@ sealed class TokenReceiveNewAnalyticsEvent( BLOCKCHAIN to blockchainName, SOURCE to tokenReceiveSource.name, ), - ) + ), AppsFlyerIncludedEvent class ButtonCopyEns( token: String, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index f7eb452cc2..693dff8739 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -1,6 +1,7 @@ package com.tangem.domain.tangempay import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AppsFlyerIncludedEvent sealed class TangemPayAnalyticsEvents( categoryName: String, @@ -11,7 +12,7 @@ sealed class TangemPayAnalyticsEvents( class ActivationScreenOpened : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa Activation Screen Opened", - ) + ), AppsFlyerIncludedEvent class ViewTermsClicked : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", @@ -21,12 +22,12 @@ sealed class TangemPayAnalyticsEvents( class GetCardClicked : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Button - Visa Get Card", - ) + ), AppsFlyerIncludedEvent class KycFlowOpened : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa KYC Flow Opened", - ) + ), AppsFlyerIncludedEvent class IssuingBannerDisplayed : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", @@ -41,17 +42,17 @@ sealed class TangemPayAnalyticsEvents( class ReceiveFundsClicked : TangemPayAnalyticsEvents( categoryName = "Visa Screen", event = "Button - Visa Receive", - ) + ), AppsFlyerIncludedEvent class AddFundsClicked : TangemPayAnalyticsEvents( categoryName = "Visa Screen", event = "Button - Visa Add Funds", - ) + ), AppsFlyerIncludedEvent class SwapClicked : TangemPayAnalyticsEvents( categoryName = "Visa Screen", event = "Button - Visa Swap", - ) + ), AppsFlyerIncludedEvent class ChooseWalletPopup : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", @@ -180,7 +181,7 @@ sealed class TangemPayAnalyticsEvents( class KycPassedAndOrderCreated : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa KYC Passed And Order Created", - ) + ), AppsFlyerIncludedEvent class KycRejected : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", From 91942930d6dd7bc6ca12662ce272c86f4ed2042b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 05:56:16 -0700 Subject: [PATCH 072/206] Updated on 2026-08-14 --- .../pay/models/response/CustomerMeResponse.kt | 9 ++ core/res/src/main/res/values/strings.xml | 5 + core/ui/src/main/res/drawable/ic_limit_20.xml | 10 ++ .../repository/DefaultOnboardingRepository.kt | 11 ++ .../tangem/domain/pay/model/CustomerInfo.kt | 2 + .../domain/pay/model/TangemPayCardLimit.kt | 32 ++++ .../tangempay/entity/TangemPayCardPageUM.kt | 3 + .../entity/TangemPayDailyLimitBlockState.kt | 22 +++ .../tangempay/model/TangemPayCardPageModel.kt | 42 ++++++ .../tangempay/ui/TangemPayCardPageScreen.kt | 63 ++++---- .../tangempay/ui/TangemPayDailyLimitBlock.kt | 140 ++++++++++++++++++ 11 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_limit_20.xml create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCardLimit.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDailyLimitBlockState.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index 906a69fa12..61fdefe5b2 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -2,6 +2,7 @@ package com.tangem.datasource.api.pay.models.response import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import java.math.BigDecimal @JsonClass(generateAdapter = true) data class CustomerMeResponse( @@ -30,6 +31,8 @@ data class CustomerMeResponse( @Json(name = "updated_at") val updatedAt: String, @Json(name = "payment_account_id") val paymentAccountId: String, @Json(name = "display_name") val displayName: String?, + @Json(name = "actual_card_limit") val actualCardLimit: CardLimit?, + @Json(name = "admin_card_limit") val adminCardLimit: CardLimit?, ) { @JsonClass(generateAdapter = false) enum class Status { @@ -71,6 +74,12 @@ data class CustomerMeResponse( } } + @JsonClass(generateAdapter = true) + data class CardLimit( + @Json(name = "amount") val amount: BigDecimal, + @Json(name = "period_type") val periodType: String, + ) + @JsonClass(generateAdapter = true) data class PaymentAccount( @Json(name = "id") val id: String, diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index e316bc77d9..2306a9be2e 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1643,6 +1643,11 @@ You can\'t initiate swap or new withdrawal till the current one is finished Withdrawal in progress Card settings + Daily limit + Current limit + Change + Daily limit unavailable + We couldn\'t load your daily limit. Please try again. Change PIN-code Come back to the app if you forget it. Digital card diff --git a/core/ui/src/main/res/drawable/ic_limit_20.xml b/core/ui/src/main/res/drawable/ic_limit_20.xml new file mode 100644 index 0000000000..4fc3491e34 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_limit_20.xml @@ -0,0 +1,10 @@ + + + diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 600f2f3ebc..aa7f791149 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -25,6 +25,8 @@ import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.CustomerInfo.CardInfo import com.tangem.domain.pay.model.CustomerInfo.ProductInstance +import com.tangem.domain.pay.model.TangemPayCardLimit +import com.tangem.domain.pay.model.TangemPayCardLimitPeriod import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.error.VisaApiError @@ -207,6 +209,8 @@ internal class DefaultOnboardingRepository @Inject constructor( cardId = instance.cardId, frozenState = cardFrozenState, displayName = if (displayName != null) CardDisplayName(displayName).getOrElse { null } else null, + actualCardLimit = instance.actualCardLimit?.parseCardLimit(), + adminCardLimit = instance.adminCardLimit?.parseCardLimit(), ) } return CustomerInfo( @@ -219,6 +223,13 @@ internal class DefaultOnboardingRepository @Inject constructor( } } + private fun CustomerMeResponse.CardLimit.parseCardLimit(): TangemPayCardLimit { + return TangemPayCardLimit( + amount = amount, + period = TangemPayCardLimitPeriod.fromString(periodType), + ) + } + private fun sendKycAnalytics(kycStatus: KycStatus) { val event = when (kycStatus) { KycStatus.APPROVED -> TangemPayAnalyticsEvents.KycPassedAndOrderCreated() diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 7c93887879..4f04e19fa0 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -51,6 +51,8 @@ data class CustomerInfo( val cardId: String, val frozenState: TangemPayCardFrozenState, val displayName: CardDisplayName?, + val actualCardLimit: TangemPayCardLimit?, + val adminCardLimit: TangemPayCardLimit?, ) data class CardInfo( diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCardLimit.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCardLimit.kt new file mode 100644 index 0000000000..63ba23859b --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCardLimit.kt @@ -0,0 +1,32 @@ +package com.tangem.domain.pay.model + +import java.math.BigDecimal +import java.util.Locale + +data class TangemPayCardLimit( + val amount: BigDecimal, + val period: TangemPayCardLimitPeriod, +) + +enum class TangemPayCardLimitPeriod { + DAY, + WEEK, + MONTH, + YEAR, + ALL_TIME, + AUTHORIZATION, + UNKNOWN, + ; + + companion object { + fun fromString(value: String) = when (value.uppercase(Locale.US)) { + "DAY" -> DAY + "WEEK" -> WEEK + "MONTH" -> MONTH + "YEAR" -> YEAR + "ALL_TIME" -> ALL_TIME + "AUTHORIZATION" -> AUTHORIZATION + else -> UNKNOWN + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt index 93bda7eba7..77b7bab3d6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt @@ -9,6 +9,7 @@ import kotlinx.collections.immutable.persistentListOf internal data class TangemPayCardPageUM( val settings: ImmutableList, val onBackClick: () -> Unit, + val dailyLimitState: TangemPayDailyLimitBlockState, val addToWalletBlockState: AddToWalletBlockState? = null, val isReissueInProgress: Boolean = false, ) { @@ -21,11 +22,13 @@ internal data class TangemPayCardPageUM( TangemPayCardPageSetting(TextReference.Str("Reissue Card")) {}, ), isReissueInProgress: Boolean = false, + dailyLimitState: TangemPayDailyLimitBlockState = TangemPayDailyLimitBlockState.Content.stub(), ) = TangemPayCardPageUM( addToWalletBlockState = addToWalletBlockState, settings = settings, onBackClick = {}, isReissueInProgress = isReissueInProgress, + dailyLimitState = dailyLimitState, ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDailyLimitBlockState.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDailyLimitBlockState.kt new file mode 100644 index 0000000000..841bad5227 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDailyLimitBlockState.kt @@ -0,0 +1,22 @@ +package com.tangem.features.tangempay.entity + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface TangemPayDailyLimitBlockState { + data object Loading : TangemPayDailyLimitBlockState + + data object Error : TangemPayDailyLimitBlockState + + data class Content( + val limit: String, + val onChangeClick: () -> Unit, + ) : TangemPayDailyLimitBlockState { + companion object { + fun stub() = Content( + limit = "$5,000", + onChangeClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 681148be62..4371b252e4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -14,11 +14,16 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayCardLimitPeriod import com.tangem.domain.pay.model.TangemPayReissueOrderInfo import com.tangem.domain.pay.model.TangemPayTopUpData +import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents @@ -33,6 +38,7 @@ import com.tangem.features.tangempay.entity.TangemPayCardPageSetting import com.tangem.features.tangempay.entity.TangemPayCardPageUM import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState import com.tangem.features.tangempay.utils.TangemPayMessagesFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -55,6 +61,7 @@ internal class TangemPayCardPageModel @Inject constructor( private val router: Router, private val analytics: AnalyticsEventHandler, private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val onboardingRepository: OnboardingRepository, private val uiMessageSender: UiMessageSender, private val reissueCardRepository: TangemPayReissueCardRepository, ) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener { @@ -70,6 +77,7 @@ internal class TangemPayCardPageModel @Inject constructor( field = MutableStateFlow( TangemPayCardPageUM( onBackClick = router::pop, + dailyLimitState = TangemPayDailyLimitBlockState.Loading, settings = persistentListOf( TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_change_pin), @@ -92,6 +100,7 @@ internal class TangemPayCardPageModel @Inject constructor( init { // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed fetchAddToWalletBanner() + fetchCardLimit() subscribeToCardFrozenState() } @@ -256,6 +265,39 @@ internal class TangemPayCardPageModel @Inject constructor( }.saveIn(addToWalletBannerJobHolder) } + private fun fetchCardLimit() { + modelScope.launch { + onboardingRepository.getCustomerInfo(params.userWalletId) + .onRight { info -> + val productInstance = info.productInstance + val cardInfo = info.cardInfo + val actualCardLimit = productInstance?.actualCardLimit + val dailyLimitState = if ( + productInstance != null && + cardInfo != null && + actualCardLimit?.period == TangemPayCardLimitPeriod.DAY + ) { + val limit = actualCardLimit.amount.format { + val symbol = getJavaCurrencyByCode(cardInfo.currencyCode).symbol + fiat(cardInfo.currencyCode, symbol) + } + TangemPayDailyLimitBlockState.Content( + limit = limit, + onChangeClick = {}, // TODO v_rodionov: #[REDACTED_TASK_KEY] + ) + } else { + TangemPayDailyLimitBlockState.Error + } + uiState.update { it.copy(dailyLimitState = dailyLimitState) } + } + .onLeft { + uiState.update { state -> + state.copy(dailyLimitState = TangemPayDailyLimitBlockState.Error) + } + } + } + } + private fun onClickAddToWallet() { router.push(TangemPayDetailsInnerRoute.AddToWallet) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 673b118af2..a9a05b551e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -21,6 +21,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text @@ -47,6 +49,7 @@ import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.entity.TangemPayCardPageSetting import com.tangem.features.tangempay.entity.TangemPayCardPageUM +import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState import kotlinx.collections.immutable.ImmutableList private const val CONTENT_FADE_DURATION_MS = 300 @@ -88,33 +91,23 @@ internal fun TangemPayCardPageScreen( ) } if (state.addToWalletBlockState != null) { - item(key = "GooglePay") { - val visibleState = remember { MutableTransitionState(false).apply { targetState = true } } - AnimatedVisibility( - visibleState = visibleState, - enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)), - exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)), - ) { - TangemPayAddToWalletBlock( - state = state.addToWalletBlockState, - ) - } + cardPageItem(key = "GooglePay") { + TangemPayAddToWalletBlock(state = state.addToWalletBlockState) } } - item(key = "Settings") { - val visibleState = remember { MutableTransitionState(false).apply { targetState = true } } - AnimatedVisibility( - visibleState = visibleState, - enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)), - exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)), - ) { - if (state.isReissueInProgress) { - TangemPayReplacingCardBlock() - } else { - TangemPayCardPageSettingsBlock( - settings = state.settings, - ) - } + cardPageItem(key = "Limit") { + TangemPayDailyLimitBlock(state = state.dailyLimitState) + } + if (state.dailyLimitState == TangemPayDailyLimitBlockState.Error) { + cardPageItem(key = "LimitError") { + TangemPayDailyLimitErrorBlock() + } + } + cardPageItem(key = "Settings") { + if (state.isReissueInProgress) { + TangemPayReplacingCardBlock() + } else { + TangemPayCardPageSettingsBlock(settings = state.settings) } } } @@ -187,6 +180,26 @@ private fun TangemPayCardPageSettingRow( } } +private fun LazyListScope.cardPageItem( + key: Any? = null, + contentType: Any? = null, + content: @Composable LazyItemScope.() -> Unit, +) { + item( + key = key, + contentType = contentType, + ) { + val visibleState = remember { MutableTransitionState(false).apply { targetState = true } } + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(CONTENT_FADE_DURATION_MS)), + exit = fadeOut(animationSpec = tween(CONTENT_FADE_DURATION_MS)), + ) { + content() + } + } +} + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt new file mode 100644 index 0000000000..c79c7aa582 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDailyLimitBlock.kt @@ -0,0 +1,140 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SecondaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerW12 +import com.tangem.core.ui.components.SpacerW8 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState + +@Composable +internal fun TangemPayDailyLimitBlock(state: TangemPayDailyLimitBlockState, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors.background.primary, + shape = TangemTheme.shapes.roundedCornersMedium, + ) + .padding(TangemTheme.dimens.spacing12), + ) { + Text( + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_title), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerH12() + CurrentLimitBlock(state) + } +} + +@Composable +private fun CurrentLimitBlock(state: TangemPayDailyLimitBlockState) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(36.dp) + .background( + color = TangemTheme.colors.icon.accent.copy(alpha = 0.1f), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_limit_20), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + ) + } + SpacerW12() + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_current_limit), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + when (state) { + TangemPayDailyLimitBlockState.Error, + is TangemPayDailyLimitBlockState.Content, + -> Text( + text = if (state is TangemPayDailyLimitBlockState.Content) state.limit else "—", + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TangemPayDailyLimitBlockState.Loading -> TextShimmer( + style = TangemTheme.typography.subtitle1, + text = "$50,000", + ) + } + } + SpacerW8() + if (state is TangemPayDailyLimitBlockState.Content) { + SecondaryButton( + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_change), + onClick = state.onChangeClick, + size = TangemButtonSize.Small, + ) + } + } +} + +@Composable +internal fun TangemPayDailyLimitErrorBlock(modifier: Modifier = Modifier) { + Notification( + config = NotificationConfig( + title = resourceReference(R.string.tangempay_card_page_daily_limit_error_title), + subtitle = resourceReference(R.string.tangempay_card_page_daily_limit_error_description), + iconResId = R.drawable.img_attention_20, + ), + containerColor = TangemTheme.colors.background.action, + modifier = modifier.fillMaxWidth(), + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun preview() = TangemThemePreview { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TangemPayDailyLimitBlock(state = TangemPayDailyLimitBlockState.Content.stub()) + TangemPayDailyLimitBlock(state = TangemPayDailyLimitBlockState.Error) + TangemPayDailyLimitBlock(state = TangemPayDailyLimitBlockState.Loading) + TangemPayDailyLimitErrorBlock() + } +} \ No newline at end of file From f3871f88318e42cceec178a35a7335525174fde0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 17:59:48 +0300 Subject: [PATCH 073/206] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 009cf6332a..498b0bcd0d 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 009cf6332a72cf0893167221abf7010d033906c2 +Subproject commit 498b0bcd0d871ed60c43b5d44f646548a4f11d37 From a0df8dd6180def3a205dede60948357c38b5a169 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 15:00:28 +0000 Subject: [PATCH 074/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index b1d2bf912f..91a743b450 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,13 +5,13 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.36-1480" +tangemBlockchainSdk = "develop-1496" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.36-607" +tangemCardSdk = "develop-602" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ -tangemVico = "2.0.0-alpha.25-tangem12" +tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ -tangemHotSdk = "develop-549" +tangemHotSdk = "develop-550" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ From 6b75b48f1eb7b40a5c5b2ad37021a80a344d3a9d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 19:03:25 +0400 Subject: [PATCH 075/206] Updated on 2026-08-14 --- common/ui/build.gradle.kts | 1 + .../converters/AmountStateConverter.kt | 2 +- .../currency/icon/CurrencyIconStateBuilder.kt | 5 +- .../CryptoCurrencyToIconStateConverter.kt | 6 +- .../common/ui/extensions/NetworkIconExt.kt | 62 ++++ .../ui/notifications/NotificationsFactory.kt | 2 +- .../ui/tokens/TokenItemStateConverter.kt | 2 +- .../core/ui/extensions/BlockchainIcons.kt | 304 ------------------ .../core/ui/extensions/CryptoCurrency.kt | 19 -- data/notifications/build.gradle.kts | 1 + .../NotificationsEligibleNetworkConverter.kt | 4 +- .../converter/BlockchainRowUMConverter.kt | 12 +- .../addtoportfolio/model/AddTokenUiBuilder.kt | 4 +- .../model/TokenActionsUiBuilder.kt | 2 +- ...nTokenWithCurrencyToListItemUMConverter.kt | 2 +- .../state/EarnFilterNetworkConverter.kt | 4 +- .../converter/UserAssetSearchItemConverter.kt | 4 +- .../TokenSelectorEntryConverter.kt | 2 +- .../utils/ui/CurrencyNetworkOperations.kt | 8 +- .../transformer/UpdateDataStateTransformer.kt | 42 +-- .../block/DefaultNFTDetailsBlockComponent.kt | 4 +- .../transformer/UpdateDataStateTransformer.kt | 4 +- .../converter/HotTokenItemStateConverter.kt | 2 +- .../entity/OnrampAddTokenUiBuilder.kt | 4 +- .../OnrampTokenItemStateConverterFactory.kt | 2 +- .../referral/model/AccountAwardConverter.kt | 2 +- .../model/NetworkSelectionModel.kt | 2 +- .../amount/model/SendAmountModel.kt | 2 +- .../transformers/SetAmountDataTransformer.kt | 2 +- .../SetInitialDataStateTransformer.kt | 2 +- .../AddStakingNotificationsTransformer.kt | 2 +- .../converter/SwapAmountFieldConverter.kt | 4 +- .../SwapChooseContentStateTransformer.kt | 2 +- .../converters/AccountTokenItemConverter.kt | 6 +- .../swap/models/states/SwapNotificationUM.kt | 2 +- .../tangem/feature/swap/ui/StateBuilder.kt | 17 +- .../feature/swap/ui/SwapScreenContent.kt | 9 +- .../state/mapper/BlockchainsMappings.kt | 4 +- .../providers/ui/BlockchainProvidersScreen.kt | 5 +- .../entity/TokenReceiveStateFactory.kt | 3 +- .../components/ExchangeStatusNotification.kt | 2 +- .../components/TokenDetailsNotification.kt | 2 +- .../factory/TokenDetailsIconStateConverter.kt | 2 +- ...nDetailsOnrampTransactionStateConverter.kt | 2 +- .../TokenDetailsSkeletonStateConverter.kt | 2 +- ...enDetailsSwapTransactionsStateConverter.kt | 2 +- ...InitializeWithCryptoCurrencyTransformer.kt | 2 +- .../CryptoCurrencyToDraggableItemConverter.kt | 2 +- .../items/OrganizeTokenItemConverter.kt | 2 +- .../SingleWalletOnrampTransactionConverter.kt | 2 +- .../WalletTokenCurrencyItemConverter.kt | 2 +- .../model/WcConnectedAppInfoModel.kt | 2 +- .../model/WcSelectNetworksModel.kt | 8 +- .../transformers/WcNetworksInfoConverter.kt | 2 +- .../converter/WcNetworkInfoUMConverter.kt | 4 +- .../blockaid/WcSpendAllowanceUMConverter.kt | 5 +- .../approve/model/YieldSupplyApproveModel.kt | 4 +- .../YieldSupplyStartEarningEntryModel.kt | 2 +- .../model/YieldSupplyStartEarningModel.kt | 4 +- .../model/YieldSupplyStopEarningModel.kt | 6 +- .../model/YieldSupplyDepositedWarningModel.kt | 2 +- 61 files changed, 193 insertions(+), 436 deletions(-) rename {core/ui/src/main/java/com/tangem/core => common/ui/src/main/java/com/tangem/common}/ui/components/currency/icon/CurrencyIconStateBuilder.kt (95%) rename {core/ui/src/main/java/com/tangem/core => common/ui/src/main/java/com/tangem/common}/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt (95%) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/extensions/NetworkIconExt.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt diff --git a/common/ui/build.gradle.kts b/common/ui/build.gradle.kts index 31eb3dc544..0cd3cd3495 100644 --- a/common/ui/build.gradle.kts +++ b/common/ui/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.core.utils) implementation(projects.core.res) implementation(projects.libs.crypto) + implementation(projects.libs.blockchainSdk) /** Project - Domain */ implementation(projects.domain.appCurrency.models) diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt index c73dd77911..a8d3ad4fe0 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/converters/AmountStateConverter.kt @@ -6,7 +6,7 @@ import com.tangem.common.ui.amountScreen.converters.field.AmountFieldConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.combinedReference import com.tangem.core.ui.extensions.orMaskWithStars diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconStateBuilder.kt b/common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/CurrencyIconStateBuilder.kt similarity index 95% rename from core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconStateBuilder.kt rename to common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/CurrencyIconStateBuilder.kt index 204df382e5..1d6029deaf 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/CurrencyIconStateBuilder.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/CurrencyIconStateBuilder.kt @@ -1,10 +1,11 @@ -package com.tangem.core.ui.components.currency.icon +package com.tangem.common.ui.components.currency.icon import androidx.annotation.DrawableRes import androidx.compose.ui.graphics.Color +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.getTintForTokenIcon -import com.tangem.core.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon import com.tangem.domain.models.currency.CryptoCurrency diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt similarity index 95% rename from core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt rename to common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt index c954f883c4..c177757a06 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverter.kt @@ -1,8 +1,8 @@ -package com.tangem.core.ui.components.currency.icon.converter +package com.tangem.common.ui.components.currency.icon.converter +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.getTintForTokenIcon -import com.tangem.core.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -91,7 +91,7 @@ class CryptoCurrencyToIconStateConverter( isGrayscale = isGrayscale, fallbackTint = tint, fallbackBackground = background, - shouldShowCustomBadge = token.isCustom && showCustomBadge, // `true` for tokens with custom derivation + shouldShowCustomBadge = token.isCustom && showCustomBadge, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/extensions/NetworkIconExt.kt b/common/ui/src/main/java/com/tangem/common/ui/extensions/NetworkIconExt.kt new file mode 100644 index 0000000000..3162bb957e --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/extensions/NetworkIconExt.kt @@ -0,0 +1,62 @@ +package com.tangem.common.ui.extensions + +import androidx.annotation.DrawableRes +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network + +/** + * Retrieves the active icon drawable resource for the network of a [CryptoCurrency]. + */ +@get:DrawableRes +val CryptoCurrency.networkIconResId: Int + get() = network.iconResId + +/** + * Retrieves the greyed-out icon drawable resource for the network of a [CryptoCurrency]. + */ +@get:DrawableRes +val CryptoCurrency.networkGreyedOutIconResId: Int + get() = network.greyedOutIconResId + +/** + * Retrieves the active icon drawable resource for this [Network]. + */ +@get:DrawableRes +val Network.iconResId: Int + get() = id.iconResId + +/** + * Retrieves the greyed-out icon drawable resource for this [Network]. + */ +@get:DrawableRes +val Network.greyedOutIconResId: Int + get() = id.greyedOutIconResId + +/** + * Retrieves the active icon drawable resource for this [Network.ID]. + */ +@get:DrawableRes +val Network.ID.iconResId: Int + get() = rawId.iconResId + +/** + * Retrieves the greyed-out icon drawable resource for this [Network.ID]. + */ +@get:DrawableRes +val Network.ID.greyedOutIconResId: Int + get() = rawId.greyedOutIconResId + +/** + * Retrieves the active icon drawable resource for this [Network.RawID]. + */ +@get:DrawableRes +val Network.RawID.iconResId: Int + get() = getActiveIconRes(toBlockchain()) + +/** + * Retrieves the greyed-out icon drawable resource for this [Network.RawID]. + */ +@get:DrawableRes +val Network.RawID.greyedOutIconResId: Int + get() = getGreyedOutIconRes(toBlockchain()) \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt index c2ea0c7fe3..4e0e7d90e5 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -4,7 +4,7 @@ import com.tangem.blockchain.common.BlockchainSdkError import com.tangem.common.ui.R import com.tangem.common.ui.amountScreen.models.AmountFieldModel import com.tangem.common.ui.amountScreen.utils.getFiatString -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.uncapped diff --git a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt index da7e2748eb..b350ff74b9 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/tokens/TokenItemStateConverter.kt @@ -4,7 +4,7 @@ import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount import com.tangem.common.ui.R import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.icons.IconTint import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt deleted file mode 100644 index 774e48671b..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/BlockchainIcons.kt +++ /dev/null @@ -1,304 +0,0 @@ -package com.tangem.core.ui.extensions - -import androidx.annotation.DrawableRes -import com.tangem.core.ui.R - -@Suppress("ComplexMethod", "LongMethod") -@DrawableRes -fun getActiveIconRes(blockchainId: String): Int { - return when (blockchainId) { - "ARBITRUM-ONE", "ARBITRUM/test" -> R.drawable.img_arbitrum_22 - "BTC", "BTC/test" -> R.drawable.img_btc_22 - "BCH" -> R.drawable.img_btc_cash_22 - "LTC" -> R.drawable.img_litecoin_22 - "ETH", "ETH/test" -> R.drawable.img_eth_22 - "ETC", "ETC/test" -> R.drawable.img_eth_classic_22 - "RSK" -> R.drawable.img_rsk_22 - "CARDANO", "CARDANO-S" -> R.drawable.img_cardano_22 - "XTZ" -> R.drawable.img_tezos_22 - "XRP" -> R.drawable.img_xrp_22 - "XLM", "XLM/test" -> R.drawable.img_stellar_22 - "AVALANCHE", "AVALANCHE/test" -> R.drawable.img_avalanche_22 - "POLYGON", "POLYGON/test" -> R.drawable.img_polygon_22 - "SOLANA", "SOLANA/test" -> R.drawable.img_solana_22 - "FTM", "FTM/test" -> R.drawable.img_fantom_22 - "BSC", "BSC/test", "BINANCE", "BINANCE/test" -> R.drawable.img_bsc_22 - "DOGE" -> R.drawable.img_dogecoin_22 - "TRON", "TRON/test" -> R.drawable.img_tron_22 - "GNO" -> R.drawable.img_gnosis_22 - "ETH-Pow", "ETH-Pow/test" -> R.drawable.img_eth_pow_22 - "ETH-Fair", "dischain" -> R.drawable.img_dischain_22 - "Polkadot", "Polkadot/test" -> R.drawable.img_polkadot_22 - "Kusama" -> R.drawable.img_kusama_22 - "OPTIMISM", "OPTIMISM/test" -> R.drawable.img_optimism_22 - "DASH" -> R.drawable.img_dash_22 - "KAS", "KAS/test" -> R.drawable.img_kaspa_22 - "The-Open-Network", "The-Open-Network/test" -> R.drawable.img_ton_22 - "KAVA", "KAVA/test" -> R.drawable.img_kava_22 - "ravencoin", "ravencoin/test" -> R.drawable.img_ravencoin_22 - "cosmos", "cosmos/test" -> R.drawable.img_cosmos_22 - "terra", "terra-luna" -> R.drawable.img_terra_22 - "terra-2", "terra-luna-2" -> R.drawable.img_terra2_22 - "cronos" -> R.drawable.img_cronos_22 - "TELOS", "TELOS/test" -> R.drawable.img_telos_22 - "aleph-zero", "aleph-zero/test" -> R.drawable.img_azero_22 - "octaspace", "octaspace/test" -> R.drawable.img_octaspace_22 - "chia", "chia/test" -> R.drawable.img_chia_22 - "NEAR", "NEAR/test" -> R.drawable.img_near_22 - "decimal", "decimal/test" -> R.drawable.img_decimal_22 - "xdc", "xdc/test" -> R.drawable.img_xdc_22 - "vechain", "vechain/test" -> R.drawable.img_vechain_22 - "aptos", "aptos/test" -> R.drawable.img_aptos_22 - "shibarium", "shibarium/test" -> R.drawable.img_shibarium_22 - "algorand", "algorand/test" -> R.drawable.img_algorand_22 - "hedera", "hedera/test" -> R.drawable.img_hedera_22 - "playa3ull" -> R.drawable.img_playa3ull_22 - "DUC" -> R.drawable.img_ducatus_22 - "aurora", "aurora/test" -> R.drawable.img_aurora_22 - "areon", "areon/test" -> R.drawable.img_areon_22 - "pls", "pls/test" -> R.drawable.img_pls_22 - "zkSyncEra", "zkSyncEra/test" -> R.drawable.img_zksync_22 - "moonbeam", "moonbeam/test" -> R.drawable.img_moonbeam_22 - "manta-pacific", "manta/test" -> R.drawable.img_manta_22 - "polygonZkEVM", "polygonZkEVM/test" -> R.drawable.img_polygon_22 - "moonriver", "moonriver/test" -> R.drawable.img_moonriver_22 - "mantle", "mantle/test" -> R.drawable.img_mantle_22 - "flare", "flare/test" -> R.drawable.img_flare_22 - "taraxa", "taraxa/test" -> R.drawable.img_taraxa_22 - "radiant" -> R.drawable.img_radiant_22 - "base" -> R.drawable.img_base_22 - "joystream" -> R.drawable.img_joystream_22 - "koinos", "koinos/test" -> R.drawable.img_koinos_22 - "bittensor" -> R.drawable.img_bittensor_22 - "blast", "blast/test" -> R.drawable.img_blast_22 - "filecoin" -> R.drawable.img_filecoin_22 - "cyber", "cyber/test" -> R.drawable.img_cyber_22 - "sei", "sei/test" -> R.drawable.img_sei_22 - "internet-computer" -> R.drawable.img_icp_22 - "sui", "sui/test" -> R.drawable.img_sui_22 - "energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22 - "energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22 - "core", "core/test" -> R.drawable.img_core_22 - "casper", "casper/test" -> R.drawable.img_casper_22 - "xodex" -> R.drawable.img_xodex_22 - "canxium" -> R.drawable.img_canxium_22 - "chiliz", "chiliz/test" -> R.drawable.img_chiliz_22 - "alephium", "alephium/test" -> R.drawable.img_alephium_22 - "clore-ai" -> R.drawable.img_clore_22 - "fact0rn" -> R.drawable.img_fact0rn_22 - "odyssey", "odyssey/test" -> R.drawable.img_odyssey_chain_22 - "bitrock", "bitrock/test" -> R.drawable.img_bitrock_22 - "sonic", "sonic/test" -> R.drawable.img_sonic_22 - "apechain", "apechain/test" -> R.drawable.img_apecoin_22 - "scroll", "scroll/test" -> R.drawable.img_scroll_22 - "zklink", "zklink/test" -> R.drawable.img_zklink_22 - "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 - "pepecoin", "pepecoin/test" -> R.drawable.img_pepecoin_22 - "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 - "quai", "quai/test" -> R.drawable.img_quai_22 - "linea", "linea/test" -> R.drawable.img_linea_22 - "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 - "plasma", "plasma/test" -> R.drawable.img_plasma_22 - "monad", "monad/test" -> R.drawable.img_monad_22 - else -> R.drawable.ic_alert_24 - } -} - -@Suppress("ComplexMethod", "LongMethod") -@DrawableRes -fun getActiveIconResByCoinId(coinId: String): Int { - return when (coinId) { - "binancecoin" -> R.drawable.img_bsc_22 - "bitcoin" -> R.drawable.img_btc_22 - "bitcoin-cash" -> R.drawable.img_btc_cash_22 - "ethereum" -> R.drawable.img_eth_22 - "arbitrum-one" -> R.drawable.img_arbitrum_22 - "optimistic-ethereum" -> R.drawable.img_optimism_22 - "ethereum-classic" -> R.drawable.img_eth_classic_22 - "stellar" -> R.drawable.img_stellar_22 - "cardano" -> R.drawable.img_cardano_22 - "matic-network", "polygon-ecosystem-token" -> R.drawable.img_polygon_22 - "avalanche-2" -> R.drawable.img_avalanche_22 - "solana" -> R.drawable.img_solana_22 - "fantom" -> R.drawable.img_fantom_22 - "tron" -> R.drawable.img_tron_22 - "polkadot" -> R.drawable.img_polkadot_22 - "litecoin" -> R.drawable.img_litecoin_22 - "rootstock" -> R.drawable.img_rsk_22 - "tezos" -> R.drawable.img_tezos_22 - "ripple" -> R.drawable.img_xrp_22 - "dogecoin" -> R.drawable.img_dogecoin_22 - "xdai" -> R.drawable.img_gnosis_22 - "ethereum-pow-iou" -> R.drawable.img_eth_pow_22 - "ethereumfair", "dischain" -> R.drawable.img_dischain_22 - "kusama" -> R.drawable.img_kusama_22 - "dash" -> R.drawable.img_dash_22 - "kaspa", "kaspa/test" -> R.drawable.img_kaspa_22 - "ton" -> R.drawable.img_ton_22 - "kava" -> R.drawable.img_kava_22 - "ravencoin" -> R.drawable.img_ravencoin_22 - "terra" -> R.drawable.img_terra_22 - "terra-2" -> R.drawable.img_terra2_22 - "telos" -> R.drawable.img_telos_22 - "octaspace" -> R.drawable.img_octaspace_22 - "chia" -> R.drawable.img_chia_22 - "near" -> R.drawable.img_near_22 - "decimal" -> R.drawable.img_decimal_22 - "xdce-crowd-sale" -> R.drawable.img_xdc_22 - "vechain" -> R.drawable.img_vechain_22 - "aptos" -> R.drawable.img_aptos_22 - "shibarium" -> R.drawable.img_shibarium_22 - "algorand" -> R.drawable.img_algorand_22 - "hedera-hashgraph" -> R.drawable.img_hedera_22 - "playa3ull-games-2" -> R.drawable.img_playa3ull_22 - "ducatus" -> R.drawable.img_ducatus_22 - "aurora-near" -> R.drawable.img_aurora_22 - "areon" -> R.drawable.img_areon_22 - "pls" -> R.drawable.img_pls_22 - "zksync-ethereum" -> R.drawable.img_zksync_22 - "moonbeam" -> R.drawable.img_moonbeam_22 - "manta-pacific" -> R.drawable.img_manta_22 - "polygon-zkevm-ethereum" -> R.drawable.img_polygon_22 - "moonriver" -> R.drawable.img_moonriver_22 - "mantle" -> R.drawable.img_mantle_22 - "flare-networks" -> R.drawable.img_flare_22 - "taraxa" -> R.drawable.img_taraxa_22 - "radiant" -> R.drawable.img_radiant_22 - "base" -> R.drawable.img_base_22 - "joystream" -> R.drawable.img_joystream_22 - "koinos", "koinos/test" -> R.drawable.img_koinos_22 - "bittensor" -> R.drawable.img_bittensor_22 - "blast", "blast/test" -> R.drawable.img_blast_22 - "filecoin" -> R.drawable.img_filecoin_22 - "cyber", "cyber/test" -> R.drawable.img_cyber_22 - "sei", "sei/test" -> R.drawable.img_sei_22 - "internet-computer" -> R.drawable.img_icp_22 - "sui", "sui/test" -> R.drawable.img_sui_22 - "energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22 - "energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22 - "core", "core/test" -> R.drawable.img_core_22 - "casper-network" -> R.drawable.img_casper_22 - "xodex" -> R.drawable.img_xodex_22 - "canxium" -> R.drawable.img_canxium_22 - "chiliz", "chiliz/test" -> R.drawable.img_chiliz_22 - "alephium", "alephium/test" -> R.drawable.img_alephium_22 - "clore-ai" -> R.drawable.img_clore_22 - "fact0rn" -> R.drawable.img_fact0rn_22 - "odyssey", "odyssey/test" -> R.drawable.img_odyssey_chain_22 - "bitrock", "bitrock/test" -> R.drawable.img_bitrock_22 - "sonic", "sonic/test" -> R.drawable.img_sonic_22 - "apechain", "apechain/test" -> R.drawable.img_apecoin_22 - "scroll", "scroll/test" -> R.drawable.img_scroll_22 - "zklink", "zklink/test" -> R.drawable.img_zklink_22 - "vanar-chain", "vanar-chain/test" -> R.drawable.img_vanar_22 - "pepecoin-network", "pepecoin-network/test" -> R.drawable.img_pepecoin_22 - "hyperliquid", "hyperliquid/test" -> R.drawable.img_hyperliquid_22 - "quai", "quai/test" -> R.drawable.img_quai_22 - "linea", "linea/test" -> R.drawable.img_linea_22 - "arbitrum-nova" -> R.drawable.img_arbitrum_nova_22 - "plasma", "plasma/test" -> R.drawable.img_plasma_22 - "monad", "monad/test" -> R.drawable.img_monad_22 - else -> R.drawable.ic_alert_24 - } -} - -@Suppress("ComplexMethod", "LongMethod") -@DrawableRes -fun getGreyedOutIconRes(blockchainId: String): Int { - return when (blockchainId) { - "ARBITRUM-ONE", "ARBITRUM/test" -> R.drawable.ic_arbitrum_22 - "BTC", "BTC/test" -> R.drawable.ic_bitcoin_16 - "BCH" -> R.drawable.ic_bitcoin_cash_16 - "LTC" -> R.drawable.ic_litecoin_22 - "ETH", "ETH/test" -> R.drawable.ic_eth_16 - "ETC", "ETC/test" -> R.drawable.ic_eth_16 - "RSK" -> R.drawable.ic_rsk_16 - "CARDANO", "CARDANO-S" -> R.drawable.ic_cardano_16 - "XTZ" -> R.drawable.ic_tezos_16 - "XRP" -> R.drawable.ic_xrp_22 - "XLM", "XLM/test" -> R.drawable.ic_stellar_16 - "AVALANCHE", "AVALANCHE/test" -> R.drawable.ic_avalanche_22 - "POLYGON", "POLYGON/test" -> R.drawable.ic_polygon_22 - "SOLANA", "SOLANA/test" -> R.drawable.ic_solana_16 - "FTM", "FTM/test" -> R.drawable.ic_fantom_22 - "BSC", "BSC/test", "BINANCE", "BINANCE/test" -> R.drawable.ic_bsc_16 - "DOGE" -> R.drawable.ic_dogecoin_16 - "TRON", "TRON/test" -> R.drawable.ic_tron_22 - "GNO" -> R.drawable.ic_gnosis_22 - "ETH-Pow", "ETH-Pow/test" -> R.drawable.ic_ethereumpow_22 - "ETH-Fair", "dischain" -> R.drawable.ic_dischain_22 - "Polkadot", "Polkadot/test" -> R.drawable.ic_polkadot_16 - "Kusama" -> R.drawable.ic_kusama_16 - "OPTIMISM", "OPTIMISM/test" -> R.drawable.ic_optimism_22 - "DASH" -> R.drawable.ic_dash_22 - "KAS", "KAS/test" -> R.drawable.ic_kaspa_22 - "The-Open-Network", "The-Open-Network/test" -> R.drawable.ic_ton_22 - "KAVA", "KAVA/test" -> R.drawable.ic_kava_22 - "ravencoin", "ravencoin/test" -> R.drawable.ic_ravencoin_22 - "cosmos", "cosmos/test" -> R.drawable.ic_cosmos_22 - "terra", "terra-luna" -> R.drawable.ic_terra_22 - "terra-2", "terra-luna-2" -> R.drawable.ic_terra2_22 - "cronos" -> R.drawable.ic_cronos_22 - "TELOS", "TELOS/test" -> R.drawable.ic_telos_22 - "aleph-zero", "aleph-zero/test" -> R.drawable.ic_azero_22 - "octaspace", "octaspace/test" -> R.drawable.ic_octaspace_22 - "chia", "chia/test" -> R.drawable.ic_chia_22 - "NEAR", "NEAR/test" -> R.drawable.ic_near_22 - "decimal", "decimal/test" -> R.drawable.ic_decimal_22 - "xdc", "xdc/test" -> R.drawable.ic_xdc_22 - "vechain", "vechain/test" -> R.drawable.ic_vechain_22 - "aptos", "aptos/test" -> R.drawable.ic_aptos_22 - "shibarium", "shibarium/test" -> R.drawable.ic_shibarium_22 - "algorand", "algorand/test" -> R.drawable.ic_algorand_22 - "hedera", "hedera/test" -> R.drawable.ic_hedera_22 - "playa3ull" -> R.drawable.ic_playa3ull_22 - "DUC" -> R.drawable.ic_ducatus_22 - "aurora", "aurora/test" -> R.drawable.ic_aurora_22 - "areon", "areon/test" -> R.drawable.ic_areon_22 - "pls", "pls/test" -> R.drawable.ic_pls_22 - "zkSyncEra", "zkSyncEra/test" -> R.drawable.ic_zksync_22 - "moonbeam", "moonbeam/test" -> R.drawable.ic_moonbeam_22 - "manta-pacific", "manta/test" -> R.drawable.ic_manta_22 - "polygonZkEVM", "polygonZkEVM/test" -> R.drawable.ic_polygon_22 - "moonriver", "moonriver/test" -> R.drawable.ic_moonriver_22 - "mantle", "mantle/test" -> R.drawable.ic_mantle_22 - "flare", "flare/test" -> R.drawable.ic_flare_22 - "taraxa", "taraxa/test" -> R.drawable.ic_taraxa_22 - "radiant" -> R.drawable.ic_radiant_22 - "base", "base/test" -> R.drawable.ic_base_22 - "joystream" -> R.drawable.ic_joystream_22 - "koinos", "koinos/test" -> R.drawable.ic_koinos_22 - "bittensor" -> R.drawable.ic_bittensor_22 - "blast", "blast/test" -> R.drawable.ic_blast_22 - "filecoin" -> R.drawable.ic_filecoin_22 - "cyber", "cyber/test" -> R.drawable.ic_cyber_22 - "sei", "sei/test" -> R.drawable.ic_sei_22 - "internet-computer" -> R.drawable.ic_icp_22 - "sui", "sui/test" -> R.drawable.ic_sui_22 - "energy-web-chain", "energy-web-chain/test" -> R.drawable.ic_energy_web_22 - "energy-web-x", "energy-web-x/test" -> R.drawable.ic_energy_web_22 - "core", "core/test" -> R.drawable.ic_core_22 - "casper", "casper/test" -> R.drawable.ic_casper_22 - "xodex" -> R.drawable.ic_xodex_22 - "canxium" -> R.drawable.ic_canxium_22 - "chiliz", "chiliz/test" -> R.drawable.ic_chiliz_22 - "alephium", "alephium/test" -> R.drawable.ic_alephium_22 - "clore-ai" -> R.drawable.ic_clore_22 - "fact0rn" -> R.drawable.ic_fact0rn_22 - "odyssey", "odyssey/test" -> R.drawable.ic_odyssey_chain_22 - "bitrock", "bitrock/test" -> R.drawable.ic_bitrock_22 - "sonic", "sonic/test" -> R.drawable.ic_sonic_22 - "apechain", "apechain/test" -> R.drawable.ic_apecoin_22 - "scroll", "scroll/test" -> R.drawable.ic_scroll_22 - "zklink", "zklink/test" -> R.drawable.ic_zklink_22 - "vanar-chain", "vanar-chain/test" -> R.drawable.ic_vanar_22 - "pepecoin", "pepecoin/test" -> R.drawable.ic_pepecoin_22 - "hyperliquid", "hyperliquid/test" -> R.drawable.ic_hyperliquid_22 - "quai", "quai/test" -> R.drawable.ic_quai_22 - "linea", "linea/test" -> R.drawable.ic_linea_22 - "arbitrum-nova" -> R.drawable.ic_arbitrum_nova_22 - "plasma", "plasma/test" -> R.drawable.ic_plasma_22 - "monad", "monad/test" -> R.drawable.ic_monad_22 - else -> R.drawable.ic_alert_24 - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt b/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt index 6c8634158c..2065184093 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/extensions/CryptoCurrency.kt @@ -1,34 +1,15 @@ package com.tangem.core.ui.extensions -import androidx.annotation.DrawableRes import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.core.graphics.toColorInt import com.tangem.core.ui.res.TangemColorPalette import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network private const val LIGHT_LUMINANCE = 0.5f private const val COLOR_HEX_START_INDEX = 2 private const val COLOR_HEX_END_INDEX = 7 -/** - * Retrieves the resource ID for the network of a [CryptoCurrency]. - * - * @return Drawable resource ID for the network. - */ -@get:DrawableRes -val CryptoCurrency.networkIconResId: Int - get() = network.iconResId - -/** - * Retrieves the resource ID. - * - * @return Drawable resource ID for the network. - */ -val Network.iconResId: Int - get() = getActiveIconRes(rawId) - /** * Tries to extract a background color from the contract address of a token. * diff --git a/data/notifications/build.gradle.kts b/data/notifications/build.gradle.kts index a54a8c6725..21476b24c2 100644 --- a/data/notifications/build.gradle.kts +++ b/data/notifications/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation(projects.core.datasource) implementation(projects.core.utils) implementation(projects.core.ui) + implementation(projects.common.ui) implementation(projects.libs.blockchainSdk) // endregion diff --git a/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt b/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt index 403718705e..77a962a417 100644 --- a/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt +++ b/data/notifications/src/main/java/com/tangem/data/notifications/converters/NotificationsEligibleNetworkConverter.kt @@ -2,7 +2,7 @@ package com.tangem.data.notifications.converters import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.datasource.api.tangemTech.models.CryptoNetworkResponse import com.tangem.domain.notifications.models.NotificationsEligibleNetwork @@ -13,7 +13,7 @@ internal object NotificationsEligibleNetworkConverter { id = value.networkId, name = blockchain.fullName, symbol = blockchain.currency, - icon = getActiveIconRes(blockchain.id), + icon = getActiveIconRes(blockchain), ) } } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt index ed70205959..f5666caa2c 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/BlockchainRowUMConverter.kt @@ -1,9 +1,10 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.converter +import com.tangem.common.ui.extensions.greyedOutIconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.components.rows.model.BlockchainRowUM -import com.tangem.core.ui.extensions.getActiveIconRes -import com.tangem.core.ui.extensions.getGreyedOutIconRes import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.models.network.Network import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.converter.Converter @@ -27,6 +28,7 @@ internal class BlockchainRowUMConverter( val isMainNetwork = network.contractAddress == null val isEnabled = !alreadyAddedNetworks.contains(network.networkId) + val networkRawId = Network.RawID(value = network.networkId) return BlockchainRowUM( id = network.networkId, @@ -34,12 +36,12 @@ internal class BlockchainRowUMConverter( type = getNetworkType(network, blockchainInfo), iconResId = if (isEnabled) { if (isSelected) { - getActiveIconRes(blockchainInfo.blockchainId) + networkRawId.iconResId } else { - getGreyedOutIconRes(blockchainInfo.blockchainId) + networkRawId.greyedOutIconResId } } else { - getGreyedOutIconRes(blockchainInfo.blockchainId) + networkRawId.greyedOutIconResId }, isMainNetwork = isMainNetwork, isSelected = isSelected, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt index 3498931b1d..3356e8a39c 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenUiBuilder.kt @@ -7,10 +7,10 @@ import com.tangem.common.ui.account.toUM import com.tangem.common.ui.addtoken.AddTokenUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.AccountStatus.* diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt index 2e6e031449..4b96d1c78a 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt @@ -6,7 +6,7 @@ import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.stringReference import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt index 66f262955c..dfdd8f7610 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/EarnTokenWithCurrencyToListItemUMConverter.kt @@ -1,6 +1,6 @@ package com.tangem.features.feed.model.converter -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.format diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/filters/state/EarnFilterNetworkConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/filters/state/EarnFilterNetworkConverter.kt index 62f5348e16..347a4269b7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/filters/state/EarnFilterNetworkConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/filters/state/EarnFilterNetworkConverter.kt @@ -2,7 +2,7 @@ package com.tangem.features.feed.model.earn.filters.state import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.fromNetworkId -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.domain.earn.model.EarnFilterNetwork import com.tangem.features.feed.ui.earn.state.EarnFilterNetworkUM import com.tangem.utils.converter.Converter @@ -22,7 +22,7 @@ internal class EarnFilterNetworkConverter : Converter.transform(state: NFTCollectionsStateUM): ImmutableList = map { - NFTCollectionUM( - id = it.collectionIdProvider(), - networkIconId = getActiveIconRes(it.network.rawId), - name = it.name.orEmpty(), - description = TextReference.PluralRes( - R.plurals.nft_collections_count, - it.count, - wrappedList(it.count), - ), - logoUrl = it.logoUrl, - assets = it.transformAssets(), - onExpandClick = { - onExpandCollectionClick(it) - }, - isExpanded = it.isExpanded(state), - ) - }.toPersistentList() + private fun List.transform(state: NFTCollectionsStateUM): ImmutableList { + return map { collection -> + NFTCollectionUM( + id = collection.collectionIdProvider(), + networkIconId = collection.network.iconResId, + name = collection.name.orEmpty(), + description = TextReference.PluralRes( + R.plurals.nft_collections_count, + collection.count, + wrappedList(collection.count), + ), + logoUrl = collection.logoUrl, + assets = collection.transformAssets(), + onExpandClick = { onExpandCollectionClick(collection) }, + isExpanded = collection.isExpanded(state), + ) + }.toPersistentList() + } private fun transformNotifications(): ImmutableList = buildList { val nftCollections = walletNFTCollections?.flattenCollections diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt index ff963aefcb..30cd672cd7 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/details/block/DefaultNFTDetailsBlockComponent.kt @@ -6,7 +6,7 @@ import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.features.nft.component.NFTDetailsBlockComponent @@ -39,7 +39,7 @@ class DefaultNFTDetailsBlockComponent @AssistedInject constructor( AccountTitleUM.Text(params.walletTitle) }, isSuccessScreen = params.isSuccessScreen, - networkIconRes = getActiveIconRes(params.nftAsset.network.rawId), + networkIconRes = params.nftAsset.network.iconResId, ) } diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt index fc0f7c8e99..ca103c70e4 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/receive/entity/transformer/UpdateDataStateTransformer.kt @@ -2,7 +2,7 @@ package com.tangem.features.nft.receive.entity.transformer import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.rows.model.ChainRowUM -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.iconResId import com.tangem.domain.models.network.Network import com.tangem.domain.nft.models.NFTNetworks import com.tangem.features.nft.receive.entity.NFTNetworkUM @@ -41,7 +41,7 @@ internal class UpdateDataStateTransformer( type = "", icon = CurrencyIconState.CoinIcon( url = null, - fallbackResId = getActiveIconRes(rawId), + fallbackResId = iconResId, isGrayscale = !enabled, shouldShowCustomBadge = custom, ), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt index a9e87d2738..557dd5b1d9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/converter/HotTokenItemStateConverter.kt @@ -1,6 +1,6 @@ package com.tangem.features.onramp.hottokens.converter -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.components.marketprice.utils.PriceChangeConverter import com.tangem.core.ui.components.token.state.TokenItemState diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt index fe12663f1f..a8408116b9 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/portfolio/entity/OnrampAddTokenUiBuilder.kt @@ -7,10 +7,10 @@ import com.tangem.common.ui.account.toUM import com.tangem.common.ui.addtoken.AddTokenUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt index 7939ad13ad..f1e42dc86e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/utils/OnrampTokenItemStateConverterFactory.kt @@ -4,7 +4,7 @@ import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt index cc1e154773..71681de87f 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/AccountAwardConverter.kt @@ -6,7 +6,7 @@ import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.PortfolioSelectUM import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt index 3a48260b02..8115f88043 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/networkselection/model/NetworkSelectionModel.kt @@ -11,7 +11,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.common.ui.account.AccountIconItemStateConverter import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.CurrencyIconStateBuilder +import com.tangem.common.ui.components.currency.icon.CurrencyIconStateBuilder import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.resourceReference diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index bb7eccfa3b..21709e238a 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -15,7 +15,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.WrappedList import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt index 8a299e98ef..43b6a85abb 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetAmountDataTransformer.kt @@ -5,7 +5,7 @@ import com.tangem.common.ui.amountScreen.converters.AmountAccountConverter import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt index c6c30d4bcb..97e0137e20 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/SetInitialDataStateTransformer.kt @@ -8,7 +8,7 @@ import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.list.RoundedListWithDividersItemData import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.crypto diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt index 1786ed1195..f4faa5db34 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/notifications/AddStakingNotificationsTransformer.kt @@ -10,7 +10,7 @@ import com.tangem.common.ui.notifications.NotificationsFactory.addFeeUnreachable import com.tangem.common.ui.notifications.NotificationsFactory.addRentExemptionNotification import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt index 0684369b44..32c4a0f2b7 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt @@ -6,7 +6,7 @@ import com.tangem.common.ui.amountScreen.converters.AmountAccountConverter import com.tangem.common.ui.amountScreen.converters.AmountStateConverter import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter import com.tangem.common.ui.amountScreen.models.AmountParameters -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.* import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account @@ -77,7 +77,7 @@ internal class SwapAmountFieldConverter( walletTitle = walletTitle, prefixText = when { swapAmountType.isEnteringField() -> resourceReference(R.string.common_from) - else -> TextReference.Companion.EMPTY + else -> TextReference.EMPTY }, ).convert(account) }, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt index 39b0819d33..438a0673b8 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/transformers/SwapChooseContentStateTransformer.kt @@ -1,6 +1,6 @@ package com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transformers -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.currency.CryptoCurrency diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt index 333bb5087c..99118c8304 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/AccountTokenItemConverter.kt @@ -9,7 +9,7 @@ import com.tangem.common.ui.account.toUM import com.tangem.common.ui.tokens.TokenItemStateConverter import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM @@ -180,14 +180,14 @@ internal class AccountTokenItemConverter( status: CryptoCurrencyStatus, appCurrency: AppCurrency, isAvailable: Boolean, - ): TokenItemState.FiatAmountState? { + ): FiatAmountState? { return when (status.value) { is CryptoCurrencyStatus.Loaded, is CryptoCurrencyStatus.Custom, is CryptoCurrencyStatus.NoQuote, is CryptoCurrencyStatus.NoAccount, -> { - TokenItemState.FiatAmountState.TextContent( + FiatAmountState.TextContent( text = status.getTotalFiatAmount().format { fiat( fiatCurrencyCode = appCurrency.code, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index 96a0293a82..d6c929a2f5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -4,7 +4,7 @@ import com.tangem.common.ui.R import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.models.currency.CryptoCurrency diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index dfb24bc959..bc7e3c6bb1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -11,7 +11,8 @@ import com.tangem.common.ui.bottomsheet.permission.state.* import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.* import com.tangem.core.ui.res.TangemTheme @@ -91,7 +92,7 @@ internal class StateBuilder( canSelectAnotherToken = false, isNotNativeToken = initialCurrencyFrom is CryptoCurrency.Token, balance = "", - networkIconRes = getActiveIconRes(initialCurrencyFrom.network.rawId), + networkIconRes = initialCurrencyFrom.network.iconResId, isBalanceHidden = true, ), receiveCardData = SwapCardState.SwapCardData( @@ -104,7 +105,7 @@ internal class StateBuilder( canSelectAnotherToken = false, balance = "", isNotNativeToken = initialCurrencyTo is CryptoCurrency.Token, - networkIconRes = initialCurrencyTo?.let { getActiveIconRes(it.network.rawId) }, + networkIconRes = initialCurrencyTo?.network?.iconResId, coinId = initialCurrencyTo?.network?.rawId, isBalanceHidden = true, ), @@ -147,7 +148,7 @@ internal class StateBuilder( tokenCurrency = fromToken.currency.symbol, canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, balance = fromToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = getActiveIconRes(fromToken.currency.network.rawId), + networkIconRes = fromToken.currency.network.iconResId, isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.Empty( @@ -198,7 +199,7 @@ internal class StateBuilder( tokenCurrency = fromToken.currency.symbol, canSelectAnotherToken = canSelectSendToken, balance = fromToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = getActiveIconRes(fromToken.currency.network.rawId), + networkIconRes = fromToken.currency.network.iconResId, isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( @@ -216,7 +217,7 @@ internal class StateBuilder( tokenCurrency = toToken.currency.symbol, canSelectAnotherToken = canSelectReceiveToken, balance = toToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = getActiveIconRes(toToken.currency.network.rawId), + networkIconRes = toToken.currency.network.iconResId, isBalanceHidden = isBalanceHiddenProvider(), ), notifications = notificationsFactory.getSwapNotSupportedNotifications(), @@ -267,7 +268,7 @@ internal class StateBuilder( isNotNativeToken = fromToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectSendToken, balance = if (!canSelectSendToken) uiStateHolder.sendCardData.balance else "", - networkIconRes = getActiveIconRes(fromToken.network.rawId), + networkIconRes = fromToken.network.iconResId, isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( @@ -283,7 +284,7 @@ internal class StateBuilder( isNotNativeToken = toToken is CryptoCurrency.Token, canSelectAnotherToken = canSelectReceiveToken, balance = if (!canSelectReceiveToken) uiStateHolder.receiveCardData.balance else "", - networkIconRes = getActiveIconRes(toToken.network.rawId), + networkIconRes = toToken.network.iconResId, isBalanceHidden = isBalanceHiddenProvider(), ), notifications = persistentListOf(), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 10d12201f0..6716c863b8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -31,13 +31,18 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState +import com.tangem.common.ui.extensions.iconResId import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.* import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SwapTokenScreenTestTags +import com.tangem.domain.models.network.Network import com.tangem.feature.swap.domain.models.ui.FeeType import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.* @@ -184,7 +189,7 @@ private fun TransactionCardData( priceImpact = priceImpact, networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null, iconPlaceholder = swapCardState.coinId?.let { - getActiveIconResByCoinId(it) + Network.RawID(it).iconResId }, onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, modifier = modifier, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/state/mapper/BlockchainsMappings.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/state/mapper/BlockchainsMappings.kt index d27b8b8c7c..2e4df298b7 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/state/mapper/BlockchainsMappings.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/state/mapper/BlockchainsMappings.kt @@ -1,7 +1,7 @@ package com.tangem.feature.tester.presentation.excludedblockchains.state.mapper import com.tangem.blockchain.common.Blockchain -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.feature.tester.presentation.excludedblockchains.state.BlockchainUM internal fun List.toUiModels( @@ -25,7 +25,7 @@ private fun Blockchain.toUiModel(isExcluded: Boolean, onExcludedStateChange: (Bo id = id, name = name, symbol = currency, - iconResId = getActiveIconRes(id), + iconResId = getActiveIconRes(this), isExcluded = isExcluded, onExcludedStateChange = onExcludedStateChange, ) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt index 744c5f95b3..71c83f80a2 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/providers/ui/BlockchainProvidersScreen.kt @@ -34,7 +34,8 @@ import com.tangem.core.ui.components.TextInputDialog import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.rows.RowText -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.ui.extensions.getActiveIconRes import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -150,7 +151,7 @@ private fun BlockchainRow(state: ProvidersUM, onExpandStateChange: () -> Unit) { ) { Row(verticalAlignment = Alignment.CenterVertically) { Image( - painter = painterResource(id = getActiveIconRes(state.blockchainId)), + painter = painterResource(id = getActiveIconRes(Blockchain.fromId(state.blockchainId))), contentDescription = null, modifier = Modifier.size(TangemTheme.dimens.size36), ) diff --git a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt index 8ca7790141..401e005148 100644 --- a/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt +++ b/features/token-recieve/impl/src/main/java/com/tangem/features/tokenreceive/entity/TokenReceiveStateFactory.kt @@ -4,8 +4,9 @@ import androidx.compose.ui.graphics.Color import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.R import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.* import com.tangem.domain.models.ReceiveAddressModel import com.tangem.domain.models.TokenReceiveNotification diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotification.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotification.kt index c36b62c6e9..87911aada9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotification.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/ExchangeStatusNotification.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.notifications.CurrencyNotificationConfig import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resourceReference diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt index feb6279306..b8f740b9fe 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Immutable import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsIconStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsIconStateConverter.kt index df9bd31b13..71b5322124 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsIconStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsIconStateConverter.kt @@ -1,7 +1,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import com.tangem.core.ui.extensions.getTintForTokenIcon -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.feature.tokendetails.presentation.tokendetails.state.IconState diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt index 52acac7843..2181819a15 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt @@ -5,7 +5,7 @@ import com.tangem.common.ui.notifications.ExpressNotificationsUM import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt index 649b2b064a..e0633eb981 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt @@ -5,7 +5,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.networkIconResId +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index bc8cbf7110..78540b1ba8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -5,7 +5,7 @@ import com.tangem.common.ui.expressStatus.state.ExpressStatusUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt index 3ecf879fe4..8a219600dc 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/InitializeWithCryptoCurrencyTransformer.kt @@ -1,6 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/CryptoCurrencyToDraggableItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/CryptoCurrencyToDraggableItemConverter.kt index cd6c995427..fbb6c4d0a4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/CryptoCurrencyToDraggableItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/CryptoCurrencyToDraggableItemConverter.kt @@ -1,6 +1,6 @@ package com.tangem.feature.wallet.child.organizetokens.model.converter.items -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.token.state.TokenItemState import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt index c1cfa0e69b..cff0b79404 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/organizetokens/model/converter/items/OrganizeTokenItemConverter.kt @@ -3,7 +3,7 @@ package com.tangem.feature.wallet.child.organizetokens.model.converter.items import androidx.compose.ui.text.SpanStyle import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.internal.TangemRowTailUM import com.tangem.core.ui.ds.row.token.TangemTokenRowUM diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt index 3df7db2bb6..0114ea6e72 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/SingleWalletOnrampTransactionConverter.kt @@ -5,7 +5,7 @@ import com.tangem.common.ui.notifications.ExpressNotificationsUM import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt index 77c5ee9b32..06c31c47b7 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokenCurrencyItemConverter.kt @@ -3,7 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import androidx.compose.ui.text.SpanStyle import com.tangem.common.getTotalCryptoAmount import com.tangem.common.getTotalFiatAmount -import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.ds.badge.* diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt index bef2fd2b82..4ce0752d54 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcConnectedAppInfoModel.kt @@ -8,7 +8,7 @@ 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.ui.UiMessageSender -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.message.SnackbarMessage diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt index f87b309971..6731ddb78e 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcSelectNetworksModel.kt @@ -1,12 +1,12 @@ package com.tangem.features.walletconnect.connections.model import androidx.compose.runtime.Stable +import com.tangem.common.ui.extensions.greyedOutIconResId +import com.tangem.common.ui.extensions.iconResId 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.ui.extensions.getGreyedOutIconRes -import com.tangem.core.ui.extensions.iconResId import com.tangem.domain.models.network.Network import com.tangem.features.walletconnect.connections.components.WcSelectNetworksComponent.WcSelectNetworksParams import com.tangem.features.walletconnect.connections.entity.WcNetworkInfoItem @@ -67,7 +67,7 @@ internal class WcSelectNetworksModel @Inject constructor( missing = params.missingRequiredNetworks.map { network -> WcNetworkInfoItem.Required( id = network.rawId, - icon = getGreyedOutIconRes(network.rawId), + icon = network.greyedOutIconResId, name = network.name, symbol = network.currencySymbol, ) @@ -95,7 +95,7 @@ internal class WcSelectNetworksModel @Inject constructor( notAdded = params.notAddedNetworks.map { network -> WcNetworkInfoItem.ReadOnly( id = network.rawId, - icon = getGreyedOutIconRes(network.rawId), + icon = network.greyedOutIconResId, name = network.name, symbol = network.currencySymbol, ) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt index ee51c67235..d57b9716e4 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/transformers/WcNetworksInfoConverter.kt @@ -1,6 +1,6 @@ package com.tangem.features.walletconnect.connections.model.transformers -import com.tangem.core.ui.extensions.iconResId +import com.tangem.common.ui.extensions.iconResId import com.tangem.domain.models.network.Network import com.tangem.features.walletconnect.connections.entity.WcNetworkInfoItem import com.tangem.features.walletconnect.connections.entity.WcNetworksInfo diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcNetworkInfoUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcNetworkInfoUMConverter.kt index 31b58c91d4..90abbf6d74 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcNetworkInfoUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcNetworkInfoUMConverter.kt @@ -1,6 +1,6 @@ package com.tangem.features.walletconnect.transaction.converter -import com.tangem.core.ui.extensions.getActiveIconRes +import com.tangem.common.ui.extensions.iconResId import com.tangem.domain.models.network.Network import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM import com.tangem.utils.converter.Converter @@ -10,6 +10,6 @@ internal class WcNetworkInfoUMConverter @Inject constructor() : Converter Date: Fri, 17 Apr 2026 14:39:32 +0400 Subject: [PATCH 076/206] Updated on 2026-08-14 --- .../tap/common/extensions/WalletManager.kt | 55 ------- .../java/com/tangem/tap/di/ActivityModule.kt | 8 +- .../tap/di/domain/WalletsDomainModule.kt | 19 +-- .../DefaultUserWalletSelectedHandler.kt | 61 ++++++++ .../di/UserWalletsListRepositoryModule.kt | 4 + .../DefaultUserWalletsListRepository.kt | 65 +++++--- .../tangem/tap/features/demo/DemoHelper.kt | 12 -- .../tangem/tap/features/demo/Extentions.kt | 10 -- .../DefaultAccessCodeRecoveryComponent.kt | 5 +- .../model/AccessCodeRecoveryModel.kt | 5 +- .../cardsettings/model/CardSettingsModel.kt | 45 +++--- .../ui/resetcard/DefaultResetCardComponent.kt | 5 +- .../ui/resetcard/model/ResetCardModel.kt | 19 +-- .../DefaultSecurityModeComponent.kt | 5 +- .../securitymode/model/SecurityModeModel.kt | 6 +- .../tangem/tap/features/main/MainViewModel.kt | 19 --- .../exchangeServices/DefaultRampManager.kt | 9 +- .../component/impl/DefaultRoutingComponent.kt | 7 +- .../DefaultUserWalletSelectedHandlerTest.kt | 147 ++++++++++++++++++ .../wallets/UserWalletSelectedHandler.kt | 15 ++ .../wallets/usecase/SelectWalletUseCase.kt | 14 +- .../features/details/utils/UserWalletSaver.kt | 5 - .../features/home/impl/model/HomeModel.kt | 3 - 23 files changed, 327 insertions(+), 216 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/DefaultUserWalletSelectedHandler.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/demo/Extentions.kt create mode 100644 app/src/test/kotlin/com/tangem/tap/domain/DefaultUserWalletSelectedHandlerTest.kt create mode 100644 domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletSelectedHandler.kt diff --git a/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt b/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt deleted file mode 100644 index 06dd58d367..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/WalletManager.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.tangem.tap.common.extensions - -import com.tangem.blockchain.common.BlockchainSdkError -import com.tangem.blockchain.common.Wallet -import com.tangem.blockchain.common.WalletManager -import com.tangem.blockchainsdk.utils.amountToCreateAccount -import com.tangem.common.services.Result -import com.tangem.tap.common.TestActions -import com.tangem.tap.domain.TapError -import com.tangem.tap.domain.getFirstToken -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.delay - -/** -[REDACTED_AUTHOR] - */ -@Deprecated( - message = "Use WalletStoresManager.fetch({userWalletId}, refresh = true) (to update all user wallet tokens)" + - "or WalletCurrenciesManager.update(...) (to update only one user wallet blockchain and its tokens) instead", -) -@Suppress("MagicNumber") -suspend fun WalletManager.safeUpdate(isDemoCard: Boolean): Result = try { - if (isDemoCard || TestActions.isTestAmountInjectionForWalletManagerEnabled) { - delay(500) - TestActions.isTestAmountInjectionForWalletManagerEnabled = false - Result.Success(wallet) - } else { - update() - Result.Success(wallet) - } -} catch (exception: Exception) { - TangemLogger.e("Error", exception) - - val networkConnectionManager = store.inject(DaggerGraphState::networkConnectionManager) - if (!networkConnectionManager.isOnline) { - Result.Failure(TapError.NoInternetConnection()) - } else { - val blockchain = wallet.blockchain - val amountToCreateAccount = blockchain.amountToCreateAccount(this, wallet.getFirstToken()) - - if (exception is BlockchainSdkError.AccountNotFound && amountToCreateAccount != null) { - Result.Failure(TapError.WalletManager.NoAccountError(amountToCreateAccount.toString())) - } else { - when (exception) { - is BlockchainSdkError -> Result.Failure(exception) - else -> { - val message = exception.cause?.localizedMessage ?: "Unknown error" - Result.Failure(TapError.WalletManager.InternalError(message)) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt index e3e54d1699..bc3f263741 100644 --- a/app/src/main/java/com/tangem/tap/di/ActivityModule.kt +++ b/app/src/main/java/com/tangem/tap/di/ActivityModule.kt @@ -4,7 +4,6 @@ import com.tangem.datasource.api.moonpay.MoonPayApi import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.tokens.repository.CurrenciesRepository @@ -14,8 +13,7 @@ import com.tangem.tap.domain.scanCard.repository.DefaultScanCardRepository import com.tangem.tap.network.exchangeServices.DefaultRampManager import com.tangem.tap.network.exchangeServices.SellService import com.tangem.tap.network.exchangeServices.moonpay.MoonPayService -import com.tangem.tap.proxy.AppStateHolder -import com.tangem.utils.Provider +import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -45,13 +43,13 @@ internal object ActivityModule { @Provides @Singleton fun provideDefaultRampManager( - appStateHolder: AppStateHolder, + sellService: SellService, expressServiceFetcher: ExpressServiceFetcher, currenciesRepository: CurrenciesRepository, dispatchers: CoroutineDispatcherProvider, ): RampStateManager { return DefaultRampManager( - sellService = Provider { requireNotNull(appStateHolder.sellService) }, + sellService = sellService, expressServiceFetcher = expressServiceFetcher, currenciesRepository = currenciesRepository, dispatchers = dispatchers, diff --git a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt index e0b447a00d..f409d2dd20 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/WalletsDomainModule.kt @@ -2,8 +2,8 @@ package com.tangem.tap.di.domain import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.transaction.WalletAddressServiceRepository import com.tangem.domain.transaction.usecase.ParseSharedAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase @@ -24,6 +24,7 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase import com.tangem.feature.wallet.presentation.wallet.domain.IsWalletNFTEnabledSyncUseCase import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase import com.tangem.operations.attestation.CardArtworksProvider +import com.tangem.tap.domain.DefaultUserWalletSelectedHandler import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module import dagger.Provides @@ -151,14 +152,14 @@ internal object WalletsDomainModule { @Provides @Singleton - fun providesSelectWalletUseCase( - userWalletsListRepository: UserWalletsListRepository, - reduxStateHolder: ReduxStateHolder, - ): SelectWalletUseCase { - return SelectWalletUseCase( - userWalletsListRepository = userWalletsListRepository, - reduxStateHolder = reduxStateHolder, - ) + fun providesSelectWalletUseCase(userWalletsListRepository: UserWalletsListRepository): SelectWalletUseCase { + return SelectWalletUseCase(userWalletsListRepository = userWalletsListRepository) + } + + @Provides + @Singleton + fun providesUserWalletSelectedHandler(handler: DefaultUserWalletSelectedHandler): UserWalletSelectedHandler { + return handler } @Provides diff --git a/app/src/main/java/com/tangem/tap/domain/DefaultUserWalletSelectedHandler.kt b/app/src/main/java/com/tangem/tap/domain/DefaultUserWalletSelectedHandler.kt new file mode 100644 index 0000000000..31ce9a9df3 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/DefaultUserWalletSelectedHandler.kt @@ -0,0 +1,61 @@ +package com.tangem.tap.domain + +import com.tangem.core.analytics.utils.TrackingContextProxy +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.wallets.UserWalletSelectedHandler +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.JobHolder +import com.tangem.utils.coroutines.saveInAndJoin +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Default implementation of [UserWalletSelectedHandler]. + * + * Runs three side effects on every selection: updates the analytics tracking context, updates the + * Tangem SDK displayed card-id numbers count (cold wallets only), and recomputes the access code + * request policy (cold wallets only). Hot wallets trigger only the tracking-context update. + * + * Invocations are serialised via [JobHolder]: if a new [invoke] arrives while the previous one is + * still running, the previous load is cancelled and the new one replaces it. The method suspends + * until the newly launched load completes. + */ +@Singleton +internal class DefaultUserWalletSelectedHandler @Inject constructor( + private val trackingContextProxy: TrackingContextProxy, + private val tangemSdkManager: TangemSdkManager, + private val settingsRepository: SettingsRepository, + private val cardSdkConfigRepository: CardSdkConfigRepository, + private val appCoroutineScope: AppCoroutineScope, +) : UserWalletSelectedHandler { + + private val loadUserWalletDataJob: JobHolder = JobHolder() + + override suspend fun invoke(userWallet: UserWallet) { + appCoroutineScope.launch { loadUserWalletData(userWallet) } + .saveInAndJoin(loadUserWalletDataJob) + } + + private suspend fun loadUserWalletData(userWallet: UserWallet) { + trackingContextProxy.setContext(userWallet) + + if (userWallet is UserWallet.Cold) { + val scanResponse = userWallet.scanResponse + tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse) + updateAccessCodeRequestPolicy(scanResponse) + } + } + + private suspend fun updateAccessCodeRequestPolicy(scanResponse: ScanResponse) { + val shouldSaveAccessCodes = settingsRepository.shouldSaveAccessCodes() + + cardSdkConfigRepository.setAccessCodeRequestPolicy( + isBiometricsRequestPolicy = shouldSaveAccessCodes && scanResponse.card.isAccessCodeSet, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt index d7d9089767..f9323c265d 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/di/UserWalletsListRepositoryModule.kt @@ -9,6 +9,7 @@ import com.tangem.common.services.secure.SecureStorage import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.hotwallet.repository.HotWalletRepository import com.tangem.domain.models.scan.serialization.* @@ -32,6 +33,7 @@ import com.tangem.tap.tangemSdkManager import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Lazy import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -56,6 +58,7 @@ internal object UserWalletsListRepositoryModule { analyticsEventHandler: AnalyticsEventHandler, hotWalletRepository: HotWalletRepository, mobileWalletPromoRepository: MobileWalletPromoRepository, + userWalletSelectedHandler: Lazy, ): UserWalletsListRepository { val moshi = buildMoshi() val secureStorage = buildSecureStorage(applicationContext = applicationContext) @@ -107,6 +110,7 @@ internal object UserWalletsListRepositoryModule { analyticsEventHandler = analyticsEventHandler, hotWalletRepository = hotWalletRepository, mobileWalletPromoRepository = mobileWalletPromoRepository, + userWalletSelectedHandler = userWalletSelectedHandler, ) } diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 80cbe137b9..c464e78a94 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -13,6 +13,7 @@ import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getSyncOrDefault +import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletTransformAction import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository.LockMethod @@ -35,6 +36,7 @@ import com.tangem.tap.domain.userWalletList.utils.* import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend import com.tangem.utils.coroutines.runSuspendCatching +import dagger.Lazy import com.tangem.utils.extensions.addOrReplace import com.tangem.utils.extensions.indexOfFirstOrNull import kotlinx.coroutines.NonCancellable @@ -60,6 +62,7 @@ internal class DefaultUserWalletsListRepository( private val analyticsEventHandler: AnalyticsEventHandler, private val hotWalletRepository: HotWalletRepository, private val mobileWalletPromoRepository: MobileWalletPromoRepository, + private val userWalletSelectedHandler: Lazy, ) : UserWalletsListRepository { override val userWallets = MutableStateFlow?>(null) @@ -88,15 +91,13 @@ internal class DefaultUserWalletsListRepository( .map { wallets.updateWith(it) } } .doOnSuccess { loadedWallets -> - userWallets.update { _ -> - val selectedUserWalletId = selectedUserWalletRepository.get() - selectedUserWallet.value = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId } - ?: loadedWallets.firstOrNull()?.also { - selectedUserWalletRepository.set(it.walletId) - } - - loadedWallets - } + val selectedUserWalletId = selectedUserWalletRepository.get() + val initialSelection = loadedWallets.firstOrNull { it.walletId == selectedUserWalletId } + ?: loadedWallets.firstOrNull()?.also { + selectedUserWalletRepository.set(it.walletId) + } + userWallets.value = loadedWallets + setSelectedUserWallet(initialSelection) } } } @@ -117,7 +118,7 @@ internal class DefaultUserWalletsListRepository( val userWallet = userWallets.value?.find { it.walletId == userWalletId } ?: raise(SelectWalletError.UnableToSelectUserWallet) selectedUserWalletRepository.set(userWalletId) - selectedUserWallet.value = userWallet + setSelectedUserWallet(userWallet) userWallet } @@ -160,7 +161,7 @@ internal class DefaultUserWalletsListRepository( // update the selectedUserWallet state if it is the only wallet if (userWallets.value?.size == 1) { selectedUserWalletRepository.set(userWallet.walletId) - selectedUserWallet.value = userWallet + setSelectedUserWallet(userWallet) } userWallet @@ -223,20 +224,24 @@ internal class DefaultUserWalletsListRepository( removeHotWalletsFromSDKAndRepos(userWalletIds) - userWallets.update { currentWallets -> - val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() } - selectedUserWallet.update { currentSelected -> - if (currentSelected == null) return@update null - val newSelected = updatedWallets?.findAvailableUserWallet( - currentWallets.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0, - ) - if (newSelected == null) { - onAllWalletsDeleted() - } - selectedUserWalletRepository.set(newSelected?.walletId) - newSelected + val currentWallets = userWallets.value + val currentSelected = selectedUserWallet.value + val updatedWallets = currentWallets?.filter { userWalletIds.contains(it.walletId).not() } + val newSelected = if (currentSelected == null) { + null + } else { + updatedWallets?.findAvailableUserWallet( + currentWallets.indexOfFirstOrNull { it.walletId == currentSelected.walletId } ?: 0, + ) + } + + userWallets.value = updatedWallets + if (currentSelected != null) { + if (newSelected == null) { + onAllWalletsDeleted() } - updatedWallets + selectedUserWalletRepository.set(newSelected?.walletId) + setSelectedUserWallet(newSelected) } } @@ -523,6 +528,18 @@ internal class DefaultUserWalletsListRepository( return tangemSdkManagerProvider.invoke().canUseBiometry && isBiometricAuthenticationUsed } + /** + * Writes [userWallet] into [selectedUserWallet] and invokes [userWalletSelectedHandler] when + * the selected [UserWalletId] actually changes. Same-id refreshes stay silent. + */ + private suspend fun setSelectedUserWallet(userWallet: UserWallet?) { + val previousId = selectedUserWallet.value?.walletId + selectedUserWallet.value = userWallet + if (userWallet != null && previousId != userWallet.walletId) { + userWalletSelectedHandler.get().invoke(userWallet) + } + } + private fun updateWallets(block: (List?) -> List?) { userWallets.update { wallets -> val updated = block(wallets) diff --git a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt index 3f8d2e44cb..8f93a43df5 100644 --- a/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/demo/DemoHelper.kt @@ -2,7 +2,6 @@ package com.tangem.tap.features.demo import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.redux.AppState object DemoHelper { val config = DemoConfig @@ -12,15 +11,4 @@ object DemoHelper { fun isTestDemoCard(scanResponse: ScanResponse): Boolean = config.isTestDemoCardId(scanResponse.card.cardId) fun isDemoCardId(cardId: String): Boolean = config.isDemoCardId(cardId) - - fun tryHandle(appState: () -> AppState?): Boolean { - val scanResponse = getScanResponse(appState) ?: return false - if (!scanResponse.isDemoCard()) return false - - return false - } - - private fun getScanResponse(appState: () -> AppState?): ScanResponse? { - return appState()?.globalState?.scanResponse - } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/demo/Extentions.kt b/app/src/main/java/com/tangem/tap/features/demo/Extentions.kt deleted file mode 100644 index 4805a4263a..0000000000 --- a/app/src/main/java/com/tangem/tap/features/demo/Extentions.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.tap.features.demo - -import com.tangem.domain.models.scan.CardDTO -import com.tangem.domain.models.scan.ScanResponse - -/** -[REDACTED_AUTHOR] - */ -fun ScanResponse.isDemoCard(): Boolean = DemoHelper.isDemoCardId(card.cardId) -fun CardDTO.isDemoCard(): Boolean = DemoHelper.isDemoCardId(cardId) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/DefaultAccessCodeRecoveryComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/DefaultAccessCodeRecoveryComponent.kt index fd75548292..8384a3bf29 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/DefaultAccessCodeRecoveryComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/DefaultAccessCodeRecoveryComponent.kt @@ -7,10 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.cardsettings.coderecovery.api.AccessCodeRecoveryComponent import com.tangem.tap.features.details.ui.cardsettings.coderecovery.model.AccessCodeRecoveryModel -import com.tangem.tap.store import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -19,6 +17,7 @@ import dagger.assisted.AssistedInject internal class DefaultAccessCodeRecoveryComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: Unit, + private val appRouter: AppRouter, ) : AccessCodeRecoveryComponent, AppComponentContext by appComponentContext { private val model: AccessCodeRecoveryModel = getOrCreateModel() @@ -29,7 +28,7 @@ internal class DefaultAccessCodeRecoveryComponent @AssistedInject constructor( AccessCodeRecoveryScreen( state = state, - onBackClick = { store.dispatchNavigationAction(AppRouter::pop) }, + onBackClick = { appRouter.pop() }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/model/AccessCodeRecoveryModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/model/AccessCodeRecoveryModel.kt index ac083db4c6..56092cb9b6 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/model/AccessCodeRecoveryModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/coderecovery/model/AccessCodeRecoveryModel.kt @@ -10,11 +10,9 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.cardsettings.coderecovery.AccessCodeRecoveryScreenState import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor import com.tangem.tap.features.details.ui.common.utils.isAccessCodeRecoveryEnabled -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -27,6 +25,7 @@ internal class AccessCodeRecoveryModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val tangemSdkManager: TangemSdkManager, private val cardSettingsInteractor: CardSettingsInteractor, + private val appRouter: AppRouter, ) : Model() { private val scannedScanResponse = cardSettingsInteractor.scannedScanResponse.value @@ -73,7 +72,7 @@ internal class AccessCodeRecoveryModel @Inject constructor( ) } - store.dispatchNavigationAction(AppRouter::pop) + appRouter.pop() } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt index ecce5aaf75..3178d30e40 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/cardsettings/model/CardSettingsModel.kt @@ -27,13 +27,11 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.cardsettings.CardInfo import com.tangem.tap.features.details.ui.cardsettings.CardSettingsScreenState import com.tangem.tap.features.details.ui.cardsettings.api.CardSettingsComponent import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor import com.tangem.tap.features.details.ui.common.utils.* -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.addIf import com.tangem.utils.logging.TangemLogger @@ -56,6 +54,7 @@ internal class CardSettingsModel @Inject constructor( private val settingsRepository: SettingsRepository, private val onboardingRepository: OnboardingRepository, private val uiMessageSender: UiMessageSender, + private val appRouter: AppRouter, ) : Model() { private val params = paramsContainer.require() @@ -188,12 +187,10 @@ internal class CardSettingsModel @Inject constructor( } is CardInfo.SecurityMode -> { Analytics.send(Settings.CardSettings.ButtonChangeSecurityMode()) - store.dispatchNavigationAction { - push(route = AppRoute.DetailsSecurity(userWalletId)) - } + appRouter.push(route = AppRoute.DetailsSecurity(userWalletId)) } is CardInfo.AccessCodeRecovery -> { - store.dispatchNavigationAction { push(AppRoute.AccessCodeRecovery) } + appRouter.push(AppRoute.AccessCodeRecovery) } else -> {} } @@ -205,30 +202,26 @@ internal class CardSettingsModel @Inject constructor( } if (scanResponse.cardTypesResolver.isTangemTwins()) { - store.dispatchNavigationAction { - push( - AppRoute.Onboarding( - scanResponse = scanResponse, - mode = AppRoute.Onboarding.Mode.RecreateWalletTwin, - ), - ) - } + appRouter.push( + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.RecreateWalletTwin, + ), + ) } else { val card = scanResponse.card modelScope.launch { val hasTangemPay = onboardingRepository.hasTangemPayInWallet(userWalletId).getOrNull() == true - store.dispatchNavigationAction { - push( - route = AppRoute.ResetToFactory( - userWalletId = userWalletId, - cardId = card.cardId, - isActiveBackupStatus = card.backupStatus?.isActive == true, - backupCardsCount = scanResponse.getBackupCardsCount() ?: 0, - hasTangemPay = hasTangemPay, - ), - ) - } + appRouter.push( + route = AppRoute.ResetToFactory( + userWalletId = userWalletId, + cardId = card.cardId, + isActiveBackupStatus = card.backupStatus?.isActive == true, + backupCardsCount = scanResponse.getBackupCardsCount() ?: 0, + hasTangemPay = hasTangemPay, + ), + ) } } } @@ -252,6 +245,6 @@ internal class CardSettingsModel @Inject constructor( private fun onBackClick() { cardSettingsInteractor.clear() - store.dispatchNavigationAction(AppRouter::pop) + appRouter.pop() } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/DefaultResetCardComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/DefaultResetCardComponent.kt index 16000cb7bb..92fcf9b4b9 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/DefaultResetCardComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/DefaultResetCardComponent.kt @@ -7,10 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent import com.tangem.tap.features.details.ui.resetcard.model.ResetCardModel -import com.tangem.tap.store import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,6 +16,7 @@ import dagger.assisted.AssistedInject internal class DefaultResetCardComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: ResetCardComponent.Params, + private val appRouter: AppRouter, ) : ResetCardComponent, AppComponentContext by appComponentContext { private val model: ResetCardModel = getOrCreateModel(params) @@ -29,7 +28,7 @@ internal class DefaultResetCardComponent @AssistedInject constructor( ResetCardScreen( modifier = modifier, state = state, - onBackClick = { store.dispatchNavigationAction(AppRouter::pop) }, + onBackClick = { appRouter.pop() }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt index 10ec357e6b..d96d1fcce0 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt @@ -3,6 +3,7 @@ package com.tangem.tap.features.details.ui.resetcard.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.routing.utils.popTo import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -18,14 +19,11 @@ import com.tangem.domain.wallets.usecase.DeleteWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.tap.common.analytics.events.Settings -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.onUserWalletSelected import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor import com.tangem.tap.features.details.ui.common.utils.getResetToFactoryDescription import com.tangem.tap.features.details.ui.resetcard.ResetCardDialog import com.tangem.tap.features.details.ui.resetcard.ResetCardScreenState import com.tangem.tap.features.details.ui.resetcard.api.ResetCardComponent -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import com.tangem.utils.logging.TangemLogger @@ -51,6 +49,7 @@ internal class ResetCardModel @Inject constructor( private val deleteWalletUseCase: DeleteWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val cardSettingsInteractor: CardSettingsInteractor, + private val appRouter: AppRouter, ) : Model() { private val params = paramsContainer.require() @@ -189,19 +188,11 @@ internal class ResetCardModel @Inject constructor( modelScope.launch { resetCardUseCase(cardId = primaryCardId, params = currentUserCodeParams).onRight { deleteSavedAccessCodesUseCase(cardId = primaryCardId) - val hasUserWallets = deleteWalletUseCase(userWalletId = currentUserWalletId).getOrElse { error -> + deleteWalletUseCase(userWalletId = currentUserWalletId).getOrElse { error -> TangemLogger.e("Unable to delete user wallet: $error") return@launch } - if (hasUserWallets) { - val newSelectedWallet = getSelectedWalletSyncUseCase().getOrElse { error -> - error("Failed to get selected wallet: $error") - } - - store.onUserWalletSelected(newSelectedWallet) - } - delay(DELAY_SDK_DIALOG_CLOSE) checkRemainingBackupCards() @@ -270,9 +261,9 @@ internal class ResetCardModel @Inject constructor( val newSelectedWallet = getSelectedWalletSyncUseCase.invoke().getOrNull() if (newSelectedWallet != null) { - store.dispatchNavigationAction { popTo() } + appRouter.popTo() } else { - store.dispatchNavigationAction { replaceAll(AppRoute.Home()) } + appRouter.replaceAll(AppRoute.Home()) } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/DefaultSecurityModeComponent.kt b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/DefaultSecurityModeComponent.kt index 151b4c123d..98ae5b6c16 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/DefaultSecurityModeComponent.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/DefaultSecurityModeComponent.kt @@ -7,10 +7,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.routing.AppRouter import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.ui.securitymode.api.SecurityModeComponent import com.tangem.tap.features.details.ui.securitymode.model.SecurityModeModel -import com.tangem.tap.store import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,6 +16,7 @@ import dagger.assisted.AssistedInject internal class DefaultSecurityModeComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted params: SecurityModeComponent.Params, + private val appRouter: AppRouter, ) : SecurityModeComponent, AppComponentContext by appComponentContext { private val model: SecurityModeModel = getOrCreateModel(params) @@ -29,7 +28,7 @@ internal class DefaultSecurityModeComponent @AssistedInject constructor( SecurityModeScreen( modifier = modifier, state = state, - onBackClick = { store.dispatchNavigationAction(AppRouter::pop) }, + onBackClick = { appRouter.pop() }, ) } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt index 36fdf29787..b6682fe212 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/securitymode/model/SecurityModeModel.kt @@ -13,13 +13,11 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.common.analytics.events.AnalyticsParam import com.tangem.tap.common.analytics.events.Settings import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.tap.features.details.redux.SecurityOption import com.tangem.tap.features.details.ui.cardsettings.domain.CardSettingsInteractor import com.tangem.tap.features.details.ui.common.utils.getAllowedSecurityOptions import com.tangem.tap.features.details.ui.common.utils.getCurrentSecurityOption import com.tangem.tap.features.details.ui.securitymode.SecurityModeScreenState -import com.tangem.tap.store import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -34,6 +32,7 @@ internal class SecurityModeModel @Inject constructor( private val cardSettingsInteractor: CardSettingsInteractor, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsErrorHandler: AnalyticsErrorHandler, + private val appRouter: AppRouter, ) : Model() { private val scannedScanResponse = cardSettingsInteractor.scannedScanResponse.value @@ -91,7 +90,7 @@ internal class SecurityModeModel @Inject constructor( is CompletionResult.Success -> { analyticsEventHandler.send(Settings.CardSettings.SecurityModeChanged(paramValue)) - store.dispatchNavigationAction(AppRouter::pop) + appRouter.pop() } is CompletionResult.Failure -> { val error = result.error @@ -99,7 +98,6 @@ internal class SecurityModeModel @Inject constructor( analyticsErrorHandler.sendErrorEvent(TangemSdkErrorEvent(error)) } } - else -> Unit } } } diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index ffca01be8a..b90a6fc412 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -21,7 +21,6 @@ import com.tangem.domain.balancehiding.BalanceHidingSettings import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.balancehiding.ListenToFlipsUseCase import com.tangem.domain.balancehiding.UpdateBalanceHidingSettingsUseCase -import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.notifications.ClearApplicationIdUseCase import com.tangem.domain.notifications.GetApplicationIdUseCase import com.tangem.domain.notifications.SendPushTokenUseCase @@ -37,7 +36,6 @@ import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase import com.tangem.domain.staking.FetchStakingOptionsUseCase import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase import com.tangem.domain.wallets.usecase.GetSavedWalletsCountUseCase -import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.security.DeviceSecurityInfoProvider @@ -80,7 +78,6 @@ internal class MainViewModel @Inject constructor( private val apiConfigsManager: ApiConfigsManager, private val multiQuoteUpdater: MultiQuoteUpdater, private val appStateHolder: AppStateHolder, - private val getSelectedWalletUseCase: GetSelectedWalletUseCase, private val appRouterConfig: AppRouterConfig, private val sellService: SellService, private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider, @@ -143,8 +140,6 @@ internal class MainViewModel @Inject constructor( launch { fetchUserCountry() } } - subscribeToSelectedWallet() - // await while initial route stack is initialized appRouterConfig.initializedState.first { it } @@ -173,20 +168,6 @@ internal class MainViewModel @Inject constructor( } } - private fun subscribeToSelectedWallet() { - getSelectedWalletUseCase.invoke() - .mapLeft { emptyFlow() } - .onRight { wallet -> - wallet.distinctUntilChanged() - .onEach { - // FIXME Do not remove this call without checking implications !!! - appStateHolder.onUserWalletSelected(it) - } - .flowOn(dispatchers.io) - .launchIn(viewModelScope) - } - } - private suspend fun fetchStakingOptions() { fetchStakingOptionsUseCase() .onLeft { TangemLogger.e("Unable to fetch staking options: $it") } diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt index 198f3b63d8..aed385bef1 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt @@ -17,7 +17,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.transaction.models.AssetRequirementsCondition -import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runCatching import com.tangem.utils.coroutines.runSuspendCatching @@ -26,7 +25,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull internal class DefaultRampManager( - private val sellService: Provider, + private val sellService: SellService, private val expressServiceFetcher: ExpressServiceFetcher, private val currenciesRepository: CurrenciesRepository, private val dispatchers: CoroutineDispatcherProvider, @@ -53,7 +52,7 @@ internal class DefaultRampManager( block = { val serviceCurrency = CryptoCurrencyConverter.convert(status.currency) - sellService().availableForSell(currency = serviceCurrency) + sellService.availableForSell(currency = serviceCurrency) }, catch = { raise(ScenarioUnavailabilityReason.NotSupportedBySellService(status.currency.name)) }, ) @@ -92,12 +91,12 @@ internal class DefaultRampManager( } override fun getSellInitializationStatus(): Flow { - return sellService.invoke().initializationStatus + return sellService.initializationStatus } override suspend fun fetchSellServiceData() { runCatching(dispatchers.io) { - sellService.invoke().update() + sellService.update() } } diff --git a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt index d922ce744d..921beac813 100644 --- a/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt +++ b/app/src/main/java/com/tangem/tap/routing/component/impl/DefaultRoutingComponent.kt @@ -40,18 +40,16 @@ import com.tangem.hot.sdk.android.create import com.tangem.sdk.api.BackupServiceHolder import com.tangem.tap.common.SnackbarHandler import com.tangem.tap.common.analytics.events.Onboarding -import com.tangem.tap.features.scanfails.ScanFailsComponent -import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy -import com.tangem.tap.features.demo.DemoHelper import com.tangem.tap.features.hot.TangemHotSDKProxy import com.tangem.tap.features.root.RootDetectedWarningComponent +import com.tangem.tap.features.scanfails.ScanFailsComponent +import com.tangem.tap.features.scanfails.ScanFailsRequesterProxy import com.tangem.tap.routing.RootContent import com.tangem.tap.routing.component.RoutingComponent import com.tangem.tap.routing.component.RoutingComponent.Child import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.tap.routing.utils.ChildFactory import com.tangem.tap.routing.utils.DeepLinkFactory -import com.tangem.tap.store import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.R import dagger.assisted.Assisted @@ -270,7 +268,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor( } private fun checkForUnfinishedBackup() { - if (DemoHelper.tryHandle { store.state }) return componentScope.launch(dispatchers.main) { val scanResponse = onboardingRepository.getUnfinishedFinalizeOnboarding() ?: return@launch messageSender.send(unfinishedBackupFoundDialog(scanResponse)) diff --git a/app/src/test/kotlin/com/tangem/tap/domain/DefaultUserWalletSelectedHandlerTest.kt b/app/src/test/kotlin/com/tangem/tap/domain/DefaultUserWalletSelectedHandlerTest.kt new file mode 100644 index 0000000000..ee434cb45c --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/domain/DefaultUserWalletSelectedHandlerTest.kt @@ -0,0 +1,147 @@ +package com.tangem.tap.domain + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.core.analytics.utils.TrackingContextProxy +import com.tangem.domain.card.configs.GenericCardConfig +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.sdk.api.TangemSdkManager +import io.mockk.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultUserWalletSelectedHandlerTest { + + private val trackingContextProxy = mockk(relaxed = true) + private val tangemSdkManager = mockk(relaxed = true) + private val settingsRepository = mockk() + private val cardSdkConfigRepository = mockk(relaxed = true) + private val appScope = TestAppCoroutineScope() + + private lateinit var handler: DefaultUserWalletSelectedHandler + + @BeforeEach + fun setup() { + clearMocks(trackingContextProxy, tangemSdkManager, settingsRepository, cardSdkConfigRepository) + handler = DefaultUserWalletSelectedHandler( + trackingContextProxy = trackingContextProxy, + tangemSdkManager = tangemSdkManager, + settingsRepository = settingsRepository, + cardSdkConfigRepository = cardSdkConfigRepository, + appCoroutineScope = appScope, + ) + } + + @Test + fun `cold wallet with access code and save-codes enabled applies biometric policy`() = runTest { + val userWallet = coldWalletWith(isAccessCodeSet = true) + coEvery { settingsRepository.shouldSaveAccessCodes() } returns true + + handler(userWallet) + + verify(exactly = 1) { trackingContextProxy.setContext(userWallet) } + verify(exactly = 1) { tangemSdkManager.changeDisplayedCardIdNumbersCount(userWallet.scanResponse) } + verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) } + } + + @Test + fun `cold wallet without access code keeps biometric policy off even when save-codes enabled`() = runTest { + assertThat(capturePolicyFor(shouldSaveAccessCodes = true, isAccessCodeSet = false)).isFalse() + } + + @Test + fun `cold wallet with access code keeps biometric policy off when save-codes disabled`() = runTest { + assertThat(capturePolicyFor(shouldSaveAccessCodes = false, isAccessCodeSet = true)).isFalse() + } + + @Test + fun `cold wallet without access code and save-codes disabled keeps biometric policy off`() = runTest { + assertThat(capturePolicyFor(shouldSaveAccessCodes = false, isAccessCodeSet = false)).isFalse() + } + + @Test + fun `hot wallet only updates tracking context`() = runTest { + val hotWallet = mockk() + + handler(hotWallet) + + verify(exactly = 1) { trackingContextProxy.setContext(hotWallet) } + verify(exactly = 0) { tangemSdkManager.changeDisplayedCardIdNumbersCount(any()) } + coVerify(exactly = 0) { settingsRepository.shouldSaveAccessCodes() } + verify(exactly = 0) { cardSdkConfigRepository.setAccessCodeRequestPolicy(any()) } + } + + @Test + fun `consecutive invocations both run policy update`() = runTest { + val firstWallet = coldWalletWith(isAccessCodeSet = true) + val secondWallet = coldWalletWith(isAccessCodeSet = false) + coEvery { settingsRepository.shouldSaveAccessCodes() } returns true + + handler(firstWallet) + handler(secondWallet) + + verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) } + verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false) } + } + + @Test + fun `new invocation cancels in-flight job so only latest side effects are applied`() = runTest { + val firstWallet = coldWalletWith(isAccessCodeSet = true) + val secondWallet = coldWalletWith(isAccessCodeSet = false) + + val firstCallGate = CompletableDeferred() + var callIndex = 0 + coEvery { settingsRepository.shouldSaveAccessCodes() } coAnswers { + callIndex++ + if (callIndex == 1) firstCallGate.await() else true + } + + val firstHandlerJob = launch { handler(firstWallet) } + runCurrent() + + handler(secondWallet) + + firstCallGate.complete(true) + firstHandlerJob.join() + + verify(exactly = 0) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) } + verify(exactly = 1) { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = false) } + verify(exactly = 1) { trackingContextProxy.setContext(secondWallet) } + } + + private suspend fun capturePolicyFor(shouldSaveAccessCodes: Boolean, isAccessCodeSet: Boolean): Boolean { + val userWallet = coldWalletWith(isAccessCodeSet = isAccessCodeSet) + coEvery { settingsRepository.shouldSaveAccessCodes() } returns shouldSaveAccessCodes + val captured = slot() + + handler(userWallet) + + verify { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = capture(captured)) } + return captured.captured + } + + private fun coldWalletWith(isAccessCodeSet: Boolean): UserWallet.Cold { + val baseScanResponse = MockScanResponseFactory.create( + cardConfig = GenericCardConfig(maxWalletCount = 2), + derivedKeys = emptyMap(), + ) + val scanResponse: ScanResponse = baseScanResponse.copy( + card = baseScanResponse.card.copy( + cardId = if (isAccessCodeSet) "CARD-WITH-CODE" else "CARD-NO-CODE", + isAccessCodeSet = isAccessCodeSet, + ), + ) + return MockUserWalletFactory.create(scanResponse = scanResponse) + } +} \ No newline at end of file diff --git a/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletSelectedHandler.kt b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletSelectedHandler.kt new file mode 100644 index 0000000000..7b5a37e76a --- /dev/null +++ b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletSelectedHandler.kt @@ -0,0 +1,15 @@ +package com.tangem.domain.common.wallets + +import com.tangem.domain.models.wallet.UserWallet + +/** + * Handler invoked when a user wallet becomes the active one. + * + * Side effects (analytics tracking context, Tangem SDK display config, access code request policy, etc.) + * follow switch-latest semantics: if a new selection arrives while a previous one is still being processed, + * the in-flight job is cancelled and only the latest selection is applied. + */ +interface UserWalletSelectedHandler { + + suspend operator fun invoke(userWallet: UserWallet) +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt index 239ada3255..98f3317583 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/SelectWalletUseCase.kt @@ -5,25 +5,23 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.SelectWalletError import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.redux.ReduxStateHolder /** - * Use case for selecting wallet + * Use case for selecting wallet. + * + * Side effects tied to selection (analytics tracking context, Tangem SDK display config, access + * code request policy) are fired from the repository itself when the selected [UserWalletId] + * changes — see the implementation of [UserWalletsListRepository.select]. * * @property userWalletsListRepository repository for getting list of user wallets - * @property reduxStateHolder redux state holder * [REDACTED_AUTHOR] */ class SelectWalletUseCase( private val userWalletsListRepository: UserWalletsListRepository, - private val reduxStateHolder: ReduxStateHolder, ) { suspend operator fun invoke(userWalletId: UserWalletId): Either { - return userWalletsListRepository.select(userWalletId).map { - reduxStateHolder.onUserWalletSelected(it) - it - } + return userWalletsListRepository.select(userWalletId) } } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt index 3e81799932..977bb4042c 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt @@ -21,7 +21,6 @@ import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.common.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.details.impl.R @@ -37,7 +36,6 @@ internal class UserWalletSaver @Inject constructor( private val scanCardProcessor: ScanCardProcessor, private val saveWalletUseCase: SaveWalletUseCase, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, - private val reduxStateHolder: ReduxStateHolder, private val messageSender: UiMessageSender, private val router: Router, ) { @@ -94,9 +92,6 @@ internal class UserWalletSaver @Inject constructor( } }, transform = { - // call only if wallet is successfully saved - reduxStateHolder.onUserWalletSelected(userWallet) - router.popTo() }, ) diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 97c6d2a93c..3e871cd61e 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -27,7 +27,6 @@ import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.SaveWalletError import com.tangem.domain.models.scan.ScanResponse -import com.tangem.domain.redux.ReduxStateHolder import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry @@ -69,7 +68,6 @@ internal class HomeModel @Inject constructor( private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val urlOpener: UrlOpener, private val userWalletsListRepository: UserWalletsListRepository, - private val reduxStateHolder: ReduxStateHolder, private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -212,7 +210,6 @@ internal class HomeModel @Inject constructor( } }, ifRight = { - reduxStateHolder.onUserWalletSelected(userWallet) setLoading(false) sendSignedInCardAnalyticsEvent(scanResponse, userWallet.isImported) appRouter.replaceAll(AppRoute.Wallet) From e20956359c5a3e64c718bc7914e9701c5986b6a5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 17 Apr 2026 20:08:45 +0400 Subject: [PATCH 077/206] Updated on 2026-08-14 --- .../com/tangem/tap/ApplicationEntryPoint.kt | 3 + .../java/com/tangem/tap/TangemApplication.kt | 26 +----- .../DefaultTransactionSignerFactory.kt | 12 ++- .../TransactionSignerFactoryModule.kt | 7 +- core/analytics/build.gradle.kts | 8 ++ .../di/LastSignedWalletFormStoreModule.kt | 16 ++++ .../SendTransactionSignerInfoInterceptor.kt | 34 ++++++++ .../store/LastSignedWalletFormStore.kt | 8 ++ ...endTransactionSignerInfoInterceptorTest.kt | 83 +++++++++++++++++++ 9 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 core/analytics/src/main/java/com/tangem/core/analytics/di/LastSignedWalletFormStoreModule.kt create mode 100644 core/analytics/src/main/java/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptor.kt create mode 100644 core/analytics/src/main/java/com/tangem/core/analytics/store/LastSignedWalletFormStore.kt create mode 100644 core/analytics/src/test/kotlin/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptorTest.kt diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 4905038b88..b8b0a9e92e 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -7,6 +7,7 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.routing.AppRouter import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.filter.OneTimeEventFilter +import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager @@ -151,4 +152,6 @@ interface ApplicationEntryPoint { fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory fun getScanFailsRequester(): ScanFailsRequester + + fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 39c1f273db..46f0aff6cb 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -18,13 +18,8 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.routing.AppRouter import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.Analytics -import com.tangem.core.analytics.api.ParamsInterceptor import com.tangem.core.analytics.filter.AppsFlyerEventFilter import com.tangem.core.analytics.filter.OneTimeEventFilter -import com.tangem.core.analytics.models.AnalyticsEvent -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.Basic -import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.decompose.ui.UiMessageSender @@ -238,6 +233,9 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val scanFailsRequester get() = entryPoint.getScanFailsRequester() + private val sendTransactionSignerInfoInterceptor + get() = entryPoint.getSendTransactionSignerInfoInterceptor() + // endregion private val appScope = MainScope() @@ -424,23 +422,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. jsonConverter = MoshiConverter.sdkMoshiConverter, ) - Analytics.addParamsInterceptor( - interceptor = object : ParamsInterceptor { - override fun id(): String = "SendTransactionSignerInfoInterceptor" - - override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = event is Basic.TransactionSent - - override fun intercept(params: MutableMap) { - val isLastSignWithRing = store.state.globalState.isLastSignWithRing - - params[AnalyticsParam.WALLET_FORM] = if (isLastSignWithRing) { - WalletForm.Ring.name - } else { - WalletForm.Card.name - } - } - }, - ) + Analytics.addParamsInterceptor(interceptor = sendTransactionSignerInfoInterceptor) factory.build(Analytics, buildData) } diff --git a/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt b/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt index e31c519069..fced2e9ee9 100644 --- a/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt +++ b/app/src/main/java/com/tangem/tap/common/libs/blockchainsdk/DefaultTransactionSignerFactory.kt @@ -3,13 +3,15 @@ package com.tangem.tap.common.libs.blockchainsdk import com.tangem.Message import com.tangem.TangemSdk import com.tangem.blockchain.common.TransactionSigner +import com.tangem.core.analytics.models.Basic.TransactionSent.WalletForm +import com.tangem.core.analytics.store.LastSignedWalletFormStore import com.tangem.data.card.TransactionSignerFactory import com.tangem.domain.card.models.TwinKey -import com.tangem.tap.common.redux.global.GlobalAction import com.tangem.tap.domain.TangemSigner -import com.tangem.tap.store -internal class DefaultTransactionSignerFactory : TransactionSignerFactory { +internal class DefaultTransactionSignerFactory( + private val lastSignedWalletFormStore: LastSignedWalletFormStore, +) : TransactionSignerFactory { override fun createTransactionSigner(cardId: String?, sdk: TangemSdk, twinKey: TwinKey?): TransactionSigner { return TangemSigner( @@ -18,7 +20,9 @@ internal class DefaultTransactionSignerFactory : TransactionSignerFactory { initialMessage = Message(), twinKey = twinKey, ) { signResponse -> - store.dispatch(action = GlobalAction.IsSignWithRing(signResponse.isRing)) + lastSignedWalletFormStore.update( + if (signResponse.isRing) WalletForm.Ring else WalletForm.Card, + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt b/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt index d0997c9038..badf261801 100644 --- a/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt +++ b/app/src/main/java/com/tangem/tap/di/libs/blockchainsdk/TransactionSignerFactoryModule.kt @@ -1,5 +1,6 @@ package com.tangem.tap.di.libs.blockchainsdk +import com.tangem.core.analytics.store.LastSignedWalletFormStore import com.tangem.data.card.TransactionSignerFactory import com.tangem.tap.common.libs.blockchainsdk.DefaultTransactionSignerFactory import dagger.Module @@ -17,7 +18,9 @@ internal class TransactionSignerFactoryModule { @Provides @Singleton - fun provideTransactionSignerFactory(): TransactionSignerFactory { - return DefaultTransactionSignerFactory() + fun provideTransactionSignerFactory( + lastSignedWalletFormStore: LastSignedWalletFormStore, + ): TransactionSignerFactory { + return DefaultTransactionSignerFactory(lastSignedWalletFormStore) } } \ No newline at end of file diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts index 819db765b9..60348ec1a4 100644 --- a/core/analytics/build.gradle.kts +++ b/core/analytics/build.gradle.kts @@ -4,6 +4,10 @@ plugins { id("configuration") } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** DI */ @@ -25,4 +29,8 @@ dependencies { /** For calculating user id hash */ implementation(tangemDeps.card.core) + + /** Tests */ + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) } \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/di/LastSignedWalletFormStoreModule.kt b/core/analytics/src/main/java/com/tangem/core/analytics/di/LastSignedWalletFormStoreModule.kt new file mode 100644 index 0000000000..cc0f32e6ac --- /dev/null +++ b/core/analytics/src/main/java/com/tangem/core/analytics/di/LastSignedWalletFormStoreModule.kt @@ -0,0 +1,16 @@ +package com.tangem.core.analytics.di + +import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor +import com.tangem.core.analytics.store.LastSignedWalletFormStore +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface LastSignedWalletFormStoreModule { + + @Binds + fun bindLastSignedWalletFormStore(impl: SendTransactionSignerInfoInterceptor): LastSignedWalletFormStore +} \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptor.kt b/core/analytics/src/main/java/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptor.kt new file mode 100644 index 0000000000..ee56dca5cf --- /dev/null +++ b/core/analytics/src/main/java/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptor.kt @@ -0,0 +1,34 @@ +package com.tangem.core.analytics.paramsinterceptor + +import com.tangem.core.analytics.api.ParamsInterceptor +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import com.tangem.core.analytics.store.LastSignedWalletFormStore +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SendTransactionSignerInfoInterceptor @Inject constructor() : + ParamsInterceptor, + LastSignedWalletFormStore { + + private val walletForm = MutableStateFlow(Basic.TransactionSent.WalletForm.Card) + + override fun update(form: Basic.TransactionSent.WalletForm) { + walletForm.value = form + } + + override fun id(): String = ID + + override fun canBeAppliedTo(event: AnalyticsEvent): Boolean = event is Basic.TransactionSent + + override fun intercept(params: MutableMap) { + params[AnalyticsParam.WALLET_FORM] = walletForm.value.name + } + + private companion object { + const val ID = "SendTransactionSignerInfoInterceptor" + } +} \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/store/LastSignedWalletFormStore.kt b/core/analytics/src/main/java/com/tangem/core/analytics/store/LastSignedWalletFormStore.kt new file mode 100644 index 0000000000..b44f753e15 --- /dev/null +++ b/core/analytics/src/main/java/com/tangem/core/analytics/store/LastSignedWalletFormStore.kt @@ -0,0 +1,8 @@ +package com.tangem.core.analytics.store + +import com.tangem.core.analytics.models.Basic + +interface LastSignedWalletFormStore { + + fun update(form: Basic.TransactionSent.WalletForm) +} \ No newline at end of file diff --git a/core/analytics/src/test/kotlin/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptorTest.kt b/core/analytics/src/test/kotlin/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptorTest.kt new file mode 100644 index 0000000000..d1f6f22bf2 --- /dev/null +++ b/core/analytics/src/test/kotlin/com/tangem/core/analytics/paramsinterceptor/SendTransactionSignerInfoInterceptorTest.kt @@ -0,0 +1,83 @@ +package com.tangem.core.analytics.paramsinterceptor + +import com.google.common.truth.Truth +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SendTransactionSignerInfoInterceptorTest { + + private lateinit var interceptor: SendTransactionSignerInfoInterceptor + + @BeforeEach + fun setUp() { + interceptor = SendTransactionSignerInfoInterceptor() + } + + @Test + fun `id returns stable identifier`() { + Truth.assertThat(interceptor.id()).isEqualTo("SendTransactionSignerInfoInterceptor") + } + + @Test + fun `canBeAppliedTo returns true for TransactionSent event`() { + val event = mockk() + + Truth.assertThat(interceptor.canBeAppliedTo(event)).isTrue() + } + + @Test + fun `canBeAppliedTo returns false for any other event`() { + val event = mockk() + + Truth.assertThat(interceptor.canBeAppliedTo(event)).isFalse() + } + + @Test + fun `intercept writes Card by default`() { + val params = mutableMapOf() + + interceptor.intercept(params) + + Truth.assertThat(params[AnalyticsParam.WALLET_FORM]) + .isEqualTo(Basic.TransactionSent.WalletForm.Card.name) + } + + @Test + fun `intercept writes last updated wallet form`() { + interceptor.update(Basic.TransactionSent.WalletForm.Ring) + val params = mutableMapOf() + + interceptor.intercept(params) + + Truth.assertThat(params[AnalyticsParam.WALLET_FORM]) + .isEqualTo(Basic.TransactionSent.WalletForm.Ring.name) + } + + @Test + fun `update overrides previous wallet form`() { + interceptor.update(Basic.TransactionSent.WalletForm.Ring) + interceptor.update(Basic.TransactionSent.WalletForm.Card) + val params = mutableMapOf() + + interceptor.intercept(params) + + Truth.assertThat(params[AnalyticsParam.WALLET_FORM]) + .isEqualTo(Basic.TransactionSent.WalletForm.Card.name) + } + + @Test + fun `intercept preserves other params`() { + val params = mutableMapOf("Source" to "Send") + + interceptor.intercept(params) + + Truth.assertThat(params).containsEntry("Source", "Send") + Truth.assertThat(params).containsKey(AnalyticsParam.WALLET_FORM) + } +} \ No newline at end of file From 85bb0d421434e7f1d596254b0103ccf6d2fc662f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 19:18:37 +0400 Subject: [PATCH 078/206] Updated on 2026-08-14 --- .../choosetoken/impl/ui/ChooseTokenScreen.kt | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt index 14b9ef03d6..5f169d8b78 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt @@ -42,6 +42,7 @@ import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags +import com.tangem.core.ui.utils.TangemSharedTransitionLayout import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM @@ -114,37 +115,39 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection() val lazyListState = rememberLazyListState() - LazyColumn( - modifier = modifier - .fillMaxSize() - .nestedScroll(nestedScrollConnection), - state = lazyListState, - contentPadding = WindowInsets.navigationBars.asPaddingValues(), - ) { - item(key = "search_bar") { - SearchBar( - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), - state = state.initialUM.searchBar, - colors = TangemSearchBarDefaults.secondaryTextFieldColors, - ) - } + TangemSharedTransitionLayout(modifier) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection), + state = lazyListState, + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { + item(key = "search_bar") { + SearchBar( + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + state = state.initialUM.searchBar, + colors = TangemSearchBarDefaults.secondaryTextFieldColors, + ) + } - assetsTitle() + assetsTitle() - if (state.portfolioBlock != null) { - walletListItem(state.portfolioBlock.walletList) - when { - state.isNotFoundState -> tokensNotFound() - state.isEmptyState -> emptyTokensList() - else -> { - tokensListItems( - tokensListData = state.portfolioBlock.tokensListData, - isBalanceHidden = state.portfolioBlock.isBalanceHidden, - ) + if (state.portfolioBlock != null) { + walletListItem(state.portfolioBlock.walletList) + when { + state.isNotFoundState -> tokensNotFound() + state.isEmptyState -> emptyTokensList() + else -> { + tokensListItems( + tokensListData = state.portfolioBlock.tokensListData, + isBalanceHidden = state.portfolioBlock.isBalanceHidden, + ) - if (state.marketsBlock != null) { - item("markets_title_spacer") { SpacerH(height = 20.dp) } - swapMarketsListItems(state.marketsBlock) + if (state.marketsBlock != null) { + item("markets_title_spacer") { SpacerH(height = 20.dp) } + swapMarketsListItems(state.marketsBlock) + } } } } From 109edf4608fac62ac59cfeea7b01b7199ae231f8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 17:22:27 +0200 Subject: [PATCH 079/206] Updated on 2026-08-14 --- .../com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt | 4 ++-- .../features/swap/v2/impl/amount/entity/SwapAmountUM.kt | 2 +- .../impl/amount/model/converter/SwapAmountFieldConverter.kt | 2 ++ .../transformers/SwapAmountChangeAmountTypeTransformer.kt | 2 ++ .../SwapAmountSecondaryReadyStateTransformer.kt | 1 + .../swap/v2/impl/amount/ui/SwapAmountBlockContent.kt | 6 +++--- .../v2/impl/amount/ui/preview/SwapAmountContentPreview.kt | 2 ++ 7 files changed, 13 insertions(+), 6 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt index 0e42de4752..f7322e95af 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/amountScreen/ui/AmountBlockV2.kt @@ -40,7 +40,7 @@ fun AmountBlockV2( isClickDisabled: Boolean, isEditingDisabled: Boolean, modifier: Modifier = Modifier, - showApproximatePrefix: Boolean = false, + shouldShowApproximatePrefix: Boolean = false, onClick: (() -> Unit)? = null, extraContent: @Composable () -> Unit = {}, ) { @@ -73,7 +73,7 @@ fun AmountBlockV2( balance = amountState.availableBalanceCrypto, currencyTitle = currencyTitle, currencyIconState = amountState.tokenIconState, - firstAmount = if (showApproximatePrefix) StringsSigns.TILDE_SIGN + firstAmount else firstAmount, + firstAmount = if (shouldShowApproximatePrefix) StringsSigns.TILDE_SIGN + firstAmount else firstAmount, secondAmount = secondAmount, isClickDisabled = isClickDisabled, isEditingDisabled = isEditingDisabled, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt index c0f9222dd3..ea00127113 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/entity/SwapAmountUM.kt @@ -86,6 +86,7 @@ sealed class SwapAmountFieldUM { val subtitleEllipsisLeft: TextEllipsis, val subtitleEllipsisRight: TextEllipsis, val isClickEnabled: Boolean, + val shouldShowApproximatePrefix: Boolean, ) : SwapAmountFieldUM() } @@ -95,7 +96,6 @@ data class PriceImpact( val amountSignificance: AmountSignificance, val type: Type, ) { - enum class Type { NONE, LOW, MEDIUM, HIGH } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt index 0684369b44..2075721a9b 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/converter/SwapAmountFieldConverter.kt @@ -39,6 +39,7 @@ internal class SwapAmountFieldConverter( isSelected: Boolean, isAmountEmpty: Boolean = true, displayAmount: BigDecimal? = null, + showApproximatePrefix: Boolean = false, ): SwapAmountFieldUM { val walletTitle = if (isSingleWallet) { resourceReference(R.string.send_from_title) @@ -60,6 +61,7 @@ internal class SwapAmountFieldConverter( subtitleRight = subtitles.subtitleRight, subtitleEllipsisRight = subtitles.subtitleEllipsisRight, isClickEnabled = true, + shouldShowApproximatePrefix = showApproximatePrefix, amountField = AmountStateConverter( clickIntents = clickIntents, appCurrency = appCurrency, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeAmountTypeTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeAmountTypeTransformer.kt index c1fde5d967..aea60fd110 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeAmountTypeTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountChangeAmountTypeTransformer.kt @@ -35,6 +35,8 @@ internal class SwapAmountChangeAmountTypeTransformer( field = field, cryptoCurrencyStatus = prevState.secondaryCryptoCurrencyStatus, isAmountEmpty = true, + ).copy( + shouldShowApproximatePrefix = swapRateType == ExpressRateType.Float, ) } ?: prevState.secondaryAmount } else { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt index f9de0f0a59..e2788cc4f1 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/transformers/SwapAmountSecondaryReadyStateTransformer.kt @@ -62,6 +62,7 @@ internal class SwapAmountSecondaryReadyStateTransformer( swapAmountType = SwapAmountType.To, cryptoCurrencyStatus = secondaryCryptoCurrencyStatus, isSelected = prevState.selectedAmountType == SwapAmountType.To, + showApproximatePrefix = selectedRateType == ExpressRateType.Float, ), secondaryCryptoCurrencyStatus = secondaryCryptoCurrencyStatus, swapCurrencies = swapCurrencies, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt index 5c2914a574..52c6ec8150 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/SwapAmountBlockContent.kt @@ -37,10 +37,10 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact +import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent @@ -121,13 +121,13 @@ private fun ConstraintLayoutScope.SwapAmountBlock( end.linkTo(parent.end) }, ) - val isFloatRate = amountUM.swapRateType == ExpressRateType.Float + val secondaryContent = amountUM.secondaryAmount as? SwapAmountFieldUM.Content AmountBlockV2( amountState = (amountUM.secondaryAmount.amountField as? AmountState.Data)?.copy( accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.send_with_swap_recipient_amount_title)), availableBalanceCrypto = TextReference.EMPTY, ) ?: amountUM.secondaryAmount.amountField, - showApproximatePrefix = isFloatRate, + shouldShowApproximatePrefix = secondaryContent?.shouldShowApproximatePrefix == true, isClickDisabled = true, isEditingDisabled = false, modifier = Modifier.constrainAs(toAmountRef) { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt index 26beb5a846..9be4de2099 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/ui/preview/SwapAmountContentPreview.kt @@ -108,6 +108,7 @@ internal data object SwapAmountContentPreview { isClickEnabled = false, subtitleEllipsisLeft = TextEllipsis.OffsetEnd(3), subtitleEllipsisRight = TextEllipsis.OffsetEnd(1), + shouldShowApproximatePrefix = false, ), secondaryAmount = SwapAmountFieldUM.Content( amountType = SwapAmountType.To, @@ -120,6 +121,7 @@ internal data object SwapAmountContentPreview { isClickEnabled = false, subtitleEllipsisLeft = TextEllipsis.End, subtitleEllipsisRight = TextEllipsis.End, + shouldShowApproximatePrefix = true, ), appCurrency = AppCurrency.Default, swapDirection = SwapDirection.Direct, From 7c9f58f088531b61d6244ea80328c716f8fb4072 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 18:27:58 +0400 Subject: [PATCH 080/206] Updated on 2026-08-14 --- .../amplitude/AmplitudeAnalyticsHandler.kt | 18 ++++++++++-- .../handlers/amplitude/AmplitudeClient.kt | 5 ++++ .../handlers/amplitude/AmplitudeLogClient.kt | 28 ------------------- .../config/environment/EnvironmentConfig.kt | 1 + .../GeneratedEnvironmentConfigConverter.kt | 1 + .../com/tangem/domain/common/LogConfig.kt | 1 - 6 files changed, 22 insertions(+), 32 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeLogClient.kt diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt index a9a25c5acc..72a24b1a69 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeAnalyticsHandler.kt @@ -3,7 +3,9 @@ package com.tangem.tap.common.analytics.handlers.amplitude import com.tangem.core.analytics.api.AnalyticsHandler import com.tangem.core.analytics.api.AnalyticsUserIdHandler import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.tap.common.analytics.AnalyticsEventsLogger import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder +import com.tangem.wallet.BuildConfig class AmplitudeAnalyticsHandler( private val client: AmplitudeAnalyticsClient, @@ -29,10 +31,20 @@ class AmplitudeAnalyticsHandler( class Builder : AnalyticsHandlerBuilder { override fun build(data: AnalyticsHandlerBuilder.Data): AnalyticsHandler { return AmplitudeAnalyticsHandler( - client = if (data.logConfig.isAmplitudeLogEnabled) { - AmplitudeLogClient(data.jsonConverter) + client = if (BuildConfig.TESTER_MENU_ENABLED) { + AmplitudeClient( + application = data.application, + key = requireNotNull(data.config.amplitudeApiKeyDev) { + "Amplitude api key not found in ${BuildConfig.BUILD_TYPE}" + }, + logger = AnalyticsEventsLogger(name = ID, jsonConverter = data.jsonConverter), + ) } else { - AmplitudeClient(data.application, data.config.amplitudeApiKey) + AmplitudeClient( + application = data.application, + key = data.config.amplitudeApiKey, + logger = null, + ) }, ) } diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeClient.kt index a5ffec919c..26e69a96e6 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeClient.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeClient.kt @@ -5,7 +5,9 @@ import com.amplitude.api.Amplitude import com.amplitude.api.AmplitudeClient import com.tangem.core.analytics.api.EventLogger import com.tangem.core.analytics.api.UserIdHolder +import com.tangem.tap.common.analytics.AnalyticsEventsLogger import com.tangem.utils.converter.Converter +import com.tangem.wallet.BuildConfig import org.json.JSONObject /** @@ -16,6 +18,7 @@ interface AmplitudeAnalyticsClient : EventLogger, UserIdHolder internal class AmplitudeClient( application: Application, key: String, + private val logger: AnalyticsEventsLogger?, ) : AmplitudeAnalyticsClient { private val client: AmplitudeClient = Amplitude.getInstance() @@ -23,6 +26,7 @@ internal class AmplitudeClient( init { client.initialize(application, key) client.enableForegroundTracking(application) + client.enableLogging(BuildConfig.TESTER_MENU_ENABLED) } override fun setUserId(userId: String) { @@ -34,6 +38,7 @@ internal class AmplitudeClient( } override fun logEvent(event: String, params: Map) { + logger?.logEvent(event, params) client.logEvent(event, ParamsToJSONObjectConverter().convert(params)) } } diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeLogClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeLogClient.kt deleted file mode 100644 index f24a5925a3..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/amplitude/AmplitudeLogClient.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.tap.common.analytics.handlers.amplitude - -import com.tangem.common.json.MoshiJsonConverter -import com.tangem.tap.common.analytics.AnalyticsEventsLogger - -/** -[REDACTED_AUTHOR] - */ -internal class AmplitudeLogClient( - jsonConverter: MoshiJsonConverter, -) : AmplitudeAnalyticsClient { - - private val logger: AnalyticsEventsLogger = AnalyticsEventsLogger(AmplitudeAnalyticsHandler.ID, jsonConverter) - - private var userId: String? = null - - override fun setUserId(userId: String) { - this.userId = userId - } - - override fun clearUserId() { - this.userId = null - } - - override fun logEvent(event: String, params: Map) { - logger.logEvent(event, params) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index 21e234d6f5..c148966eba 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -10,6 +10,7 @@ data class EnvironmentConfig( val mercuryoWidgetId: String = "", val mercuryoSecret: String = "", val amplitudeApiKey: String = "", + val amplitudeApiKeyDev: String? = null, val appsFlyerApiKey: String = "", val appsAppId: String = "", val blockchainSdkConfig: BlockchainSdkConfig = BlockchainSdkConfig(), diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt index 28644f5c6b..f1f941c2b4 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt @@ -28,6 +28,7 @@ internal object GeneratedEnvironmentConfigConverter { mercuryoSecret = GeneratedEnvironmentConfig.mercuryoSecret, blockchainSdkConfig = createBlockchainSdkConfig(), amplitudeApiKey = GeneratedEnvironmentConfig.amplitudeApiKey, + amplitudeApiKeyDev = GeneratedEnvironmentConfig.amplitudeApiKeyDev, appsFlyerApiKey = AppsFlyer.appsFlyerDevKey, appsAppId = AppsFlyer.appsFlyerAppID, walletConnectProjectId = GeneratedEnvironmentConfig.walletConnectProjectId, diff --git a/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt b/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt index 840894f1ad..fb5ca2619c 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/common/LogConfig.kt @@ -15,7 +15,6 @@ object NetworkLogConfig { object AnalyticsHandlersLogConfig { val isFirebaseLogEnabled: Boolean = BuildConfig.LOG_ENABLED - val isAmplitudeLogEnabled: Boolean = BuildConfig.LOG_ENABLED val isAppsflyerLogEnabled: Boolean = BuildConfig.LOG_ENABLED val isCustomerIoLogEnabled: Boolean = BuildConfig.LOG_ENABLED } \ No newline at end of file From ece4c0168068b3abe62c60a616295ab37429ba77 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 09:01:43 +0200 Subject: [PATCH 081/206] Updated on 2026-08-14 --- app/src/main/java/com/tangem/tap/MainActivity.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 5372f06248..364671d88f 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -19,6 +19,8 @@ import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.net.toUri @@ -244,7 +246,11 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { setContent { CompositionLocalProvider(LocalUserInteractionTracker provides userInteractionTracker) { - routingComponent.Content(Modifier.fillMaxSize()) + routingComponent.Content( + Modifier + .fillMaxSize() + .semantics { testTagsAsResourceId = true }, + ) } } } From c846d8c2767a725d8035feec1afc8bcbeb689441 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 09:28:48 +0100 Subject: [PATCH 082/206] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 9 + .../com/tangem/common/routing/AppRoute.kt | 3 + .../configs/feature_toggles_config.json | 5 +- .../create-wallet-start/impl/build.gradle.kts | 12 + .../CreateWalletStartModel.kt | 11 +- .../CreateWalletStartModelTest.kt | 651 ++++++++++++++++++ .../v2/OnboardingV2FeatureToggles.kt | 1 + .../v2/DefaultOnboardingV2FeatureToggles.kt | 2 + .../v2/addresssync/AddressSyncComponent.kt | 13 + .../DefaultAddressSyncComponent.kt | 39 ++ .../di/AddressSyncComponentModule.kt | 18 + .../addresssync/di/AddressSyncModelModule.kt | 20 + .../v2/addresssync/entity/AddressSyncUM.kt | 5 + .../v2/addresssync/model/AddressSyncModel.kt | 22 + .../v2/addresssync/ui/AddressSyncContent.kt | 54 ++ 15 files changed, 862 insertions(+), 3 deletions(-) create mode 100644 features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 588ae2ef38..72e8f32bb6 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -25,6 +25,7 @@ import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.nft.component.NFTComponent +import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent import com.tangem.features.onramp.component.* import com.tangem.features.pushnotifications.api.PushNotificationsComponent @@ -111,6 +112,7 @@ internal class ChildFactory @Inject constructor( private val kycComponentFactory: KycComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val feedEntryComponentFactory: FeedEntryComponent.Factory, + private val addressSyncComponentFactory: AddressSyncComponent.Factory, ) { @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -692,6 +694,13 @@ internal class ChildFactory @Inject constructor( componentFactory = feedEntryComponentFactory, ) } + is AppRoute.AddressSync -> { + createComponentChild( + context = context, + params = AddressSyncComponent.Params(data = Unit), + componentFactory = addressSyncComponentFactory, + ) + } } } } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index e78861b261..fd3d57f697 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -484,4 +484,7 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId") + + @Serializable + data object AddressSync : AppRoute(path = "/address_sync") } \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index d4422d4bf3..d4053a9ee6 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -7,7 +7,6 @@ "name": "VISA_ONBOARDING_ENABLED", "version": "undefined" }, - { "name": "STAKING_ETH_ENABLED", "version": "undefined" @@ -67,5 +66,9 @@ { "name": "WALLET_CONNECT_BITCOIN_ENABLED", "version": "undefined" + }, + { + "name": "ADDRESS_SYNC_ENABLED", + "version": "undefined" } ] diff --git a/features/create-wallet-start/impl/build.gradle.kts b/features/create-wallet-start/impl/build.gradle.kts index c280d4160c..f62c9a51f6 100644 --- a/features/create-wallet-start/impl/build.gradle.kts +++ b/features/create-wallet-start/impl/build.gradle.kts @@ -11,10 +11,15 @@ android { namespace = "com.tangem.features.createwalletstart.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.createWalletStart.api) implementation(projects.features.hotWallet.api) + implementation(projects.features.onboardingV2.api) /** Project - Domain */ implementation(projects.domain.card) @@ -71,4 +76,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) } \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 5f590c887c..4344425538 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -33,14 +33,15 @@ import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.createwalletstart.entity.CreateWalletStartUM +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val HIDE_PROGRESS_DELAY = 400L @@ -65,6 +66,7 @@ internal class CreateWalletStartModel @Inject constructor( private val trackingContextProxy: TrackingContextProxy, private val analyticsEventHandler: AnalyticsEventHandler, private val appsFlyerStore: AppsFlyerStore, + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -234,7 +236,12 @@ internal class CreateWalletStartModel @Inject constructor( }, ifRight = { setLoading(false) - appRouter.replaceAll(AppRoute.Wallet) + val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { + AppRoute.AddressSync + } else { + AppRoute.Wallet + } + appRouter.replaceAll(route) }, ) } diff --git a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt new file mode 100644 index 0000000000..2d780979b5 --- /dev/null +++ b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt @@ -0,0 +1,651 @@ +package com.tangem.features.createwalletstart + +import arrow.core.left +import arrow.core.right +import com.tangem.common.core.TangemError +import com.tangem.common.core.TangemSdkError +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +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.analytics.utils.TrackingContextProxy +import com.tangem.core.decompose.model.MutableParamsContainer +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.navigation.url.UrlOpener +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.datasource.local.appsflyer.AppsFlyerStore +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.card.analytics.IntroductionProcess +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.error.SaveWalletError +import com.tangem.domain.hotwallet.IsHotWalletCreationSupported +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.models.AppsFlyerConversionData +import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.createwalletstart.entity.CreateWalletStartUM +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class CreateWalletStartModelTest { + + private val router: Router = mockk(relaxed = true) + private val appRouter: AppRouter = mockk(relaxed = true) + private val scanCardProcessor: ScanCardProcessor = mockk() + private val cardSdkConfigRepository: CardSdkConfigRepository = mockk(relaxed = true) + private val settingsRepository: SettingsRepository = mockk() + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk() + private val coldUserWalletBuilder: ColdUserWalletBuilder = mockk() + private val saveWalletUseCase: SaveWalletUseCase = mockk() + private val isHotWalletCreationSupported: IsHotWalletCreationSupported = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase = mockk() + private val urlOpener: UrlOpener = mockk(relaxed = true) + private val trackingContextProxy: TrackingContextProxy = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val appsFlyerStore: AppsFlyerStore = mockk() + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles = mockk() + + private val testUserWalletId = UserWalletId("1234567890ABCDEF") + private val testScanResponse: ScanResponse = mockk(relaxed = true) + private val testColdWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns testUserWalletId + } + + @BeforeEach + fun setUp() { + coEvery { appsFlyerStore.get() } returns null + coEvery { settingsRepository.shouldSaveAccessCodes() } returns false + every { coldUserWalletBuilderFactory.create(any()) } returns coldUserWalletBuilder + every { coldUserWalletBuilder.build() } returns testColdWallet + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } just Runs + } + + @Test + fun `GIVEN ColdWallet mode WHEN onScanClick THEN ButtonScanCard event sent AND scan called`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { + it.source == AnalyticsParam.ScreensSources.CreateWalletIntro + }, + ) + } + coVerify { + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + shouldCheckIsAlreadyActivated = true, + cardId = null, + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } + } + + @Test + fun `GIVEN HotWallet mode WHEN onScanClick THEN ButtonScanCard event sent AND scan called`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { + it.source == AnalyticsParam.ScreensSources.CreateWalletIntro + }, + ) + } + coVerify { + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + shouldCheckIsAlreadyActivated = true, + cardId = null, + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } + } + + @Test + fun `GIVEN ColdWallet AND hot wallet not supported WHEN otherMethodClick THEN dialog sent`() = runTest { + every { isHotWalletCreationSupported() } returns false + every { isHotWalletCreationSupported.getLeastVersionName() } returns "13" + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.otherMethodClick.invoke() + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify { + analyticsEventHandler.send( + match { true }, + ) + } + verify { uiMessageSender.send(match { true }) } + verify(exactly = 0) { router.push(any(), any()) } + } + + @Test + fun `GIVEN HotWallet AND hot wallet supported WHEN onPrimaryButtonClick THEN CreateMobileWallet pushed`() = + runTest { + every { isHotWalletCreationSupported() } returns true + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + advanceUntilIdle() + + model.uiState.value.onPrimaryButtonClick.invoke() + advanceUntilIdle() + + verify { trackingContextProxy.addHotWalletContext() } + verify { + analyticsEventHandler.send( + match { true }, + ) + } + verify { + router.push( + route = AppRoute.CreateMobileWallet( + source = AnalyticsParam.ScreensSources.CreateWalletIntro.value, + ), + onComplete = any(), + ) + } + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN ColdWallet mode WHEN onPrimaryButtonClick THEN buy link opened`() = runTest { + val testUrl = "https://buy.tangem.com" + coEvery { + generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) + } returns testUrl + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onPrimaryButtonClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { true }, + ) + } + coVerify { generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) } + verify { urlOpener.openUrl(testUrl) } + } + + @Test + fun `GIVEN HotWallet mode WHEN onBuyClick THEN buy link opened`() = runTest { + val testUrl = "https://buy.tangem.com" + coEvery { + generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) + } returns testUrl + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + advanceUntilIdle() + + model.uiState.value.otherMethodClick.invoke() + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { true }, + ) + } + coVerify { generateBuyTangemCardLinkUseCase.invoke(GenerateBuyTangemCardLinkUseCase.Source.Creation) } + verify { urlOpener.openUrl(testUrl) } + } + + @Test + fun `WHEN scanCard THEN access code policy set AND scanProcessor called`() = runTest { + coEvery { settingsRepository.shouldSaveAccessCodes() } returns true + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { cardSdkConfigRepository.setAccessCodeRequestPolicy(isBiometricsRequestPolicy = true) } + coVerify { + scanCardProcessor.scan( + analyticsSource = AnalyticsParam.ScreensSources.Intro, + shouldCheckIsAlreadyActivated = true, + cardId = null, + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } + } + + @Test + fun `GIVEN NfcFeatureIsUnavailable WHEN handleScanError THEN nfcFeatureUnavailable dialog sent`() = runTest { + val error = TangemSdkError.NfcFeatureIsUnavailable() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onFailure = arg Unit>(7) + onFailure.invoke(error) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { uiMessageSender.send(match { true }) } + } + + @Test + fun `GIVEN generic TangemSdkError WHEN handleScanError THEN no dialog sent`() = runTest { + val error: TangemSdkError = mockk(relaxed = true) + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onFailure = arg Unit>(7) + onFailure.invoke(error) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN non-sdk TangemError WHEN handleScanError THEN no dialog sent`() = runTest { + val error: TangemError = mockk(relaxed = true) + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onFailure = arg Unit>(7) + onFailure.invoke(error) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { uiMessageSender.send(any()) } + } + + @Test + fun `GIVEN builder returns null WHEN proceedWithScanResponse THEN saveWalletUseCase not called`() = runTest { + every { coldUserWalletBuilder.build() } returns null + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { coldUserWalletBuilderFactory.create(scanResponse = testScanResponse) } + coVerify(exactly = 0) { saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) } + verify(exactly = 0) { appRouter.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN save returns WalletAlreadySaved WHEN proceedWithScanResponse THEN unlock called AND Wallet replaced`() = + runTest { + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns SaveWalletError.WalletAlreadySaved(messageId = 0).left() + coEvery { + userWalletsListRepository.unlock( + userWalletId = testUserWalletId, + unlockMethod = any(), + ) + } returns Unit.right() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + coVerify { + userWalletsListRepository.unlock( + userWalletId = testUserWalletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(testScanResponse), + ) + } + verify { appRouter.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) } + } + + @Test + fun `GIVEN save returns DataError WHEN proceedWithScanResponse THEN no unlock AND no replace`() = runTest { + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns SaveWalletError.DataError(messageId = 0).left() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + coVerify(exactly = 0) { userWalletsListRepository.unlock(any(), any()) } + verify(exactly = 0) { appRouter.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN save success AND isAddressSyncEnabled disabled WHEN proceedWithScanResponse THEN Wallet replaced`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns Unit.right() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { appRouter.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) } + } + + @Test + fun `GIVEN save success AND isAddressSyncEnabled WHEN proceedWithScanResponse THEN AddressSync replaced`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns true + coEvery { + saveWalletUseCase(userWallet = any(), canOverride = any(), analyticsSource = any()) + } returns Unit.right() + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any() + ) + } coAnswers { + val onSuccess = arg Unit>(8) + onSuccess.invoke(testScanResponse) + } + + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { appRouter.replaceAll(routes = arrayOf(AppRoute.AddressSync), onComplete = any()) } + } + + @Test + fun `WHEN model initialized THEN CreateWalletIntroScreenOpened event sent with referral id`() = runTest { + val refcode = "referralCode" + coEvery { appsFlyerStore.get() } returns AppsFlyerConversionData(refcode = refcode, campaign = null) + + createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + advanceUntilIdle() + + verify { + analyticsEventHandler.send( + match { true }, + ) + } + } + + @Test + fun `WHEN ColdWallet WHEN get model THEN correct resources`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.ColdWallet) + val state = model.uiState.value + assert(state.title == resourceReference(R.string.common_tangem_wallet)) + assert(state.description == resourceReference(R.string.welcome_create_wallet_hardware_description)) + assert( + state.featureItems == persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_class), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_delivery), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_sparkles_16, + text = resourceReference(R.string.welcome_create_wallet_feature_use), + ) + ) + ) + assert(state.imageResId == R.drawable.img_hardware_wallet) + assert(state.shouldShowScanSecondaryButton) + assert(state.primaryButtonText == resourceReference(R.string.details_buy_wallet),) + assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_mobile_title),) + assert(state.otherMethodDescription == null,) + assert(state.isScanInProgress.not()) + } + + @Test + fun `WHEN HotWallet WHEN get model THEN correct resources`() = runTest { + val model = createModel(testScope = this, mode = CreateWalletStartComponent.Mode.HotWallet) + val state = model.uiState.value + assert(state.title == resourceReference(R.string.hw_mobile_wallet)) + assert(state.description == resourceReference(R.string.welcome_create_wallet_mobile_description_full)) + assert( + state.featureItems == persistentListOf( + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_shield_check_16, + text = resourceReference(R.string.welcome_create_wallet_feature_seamless), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_flash_16, + text = resourceReference(R.string.welcome_create_wallet_feature_one_tap), + ), + CreateWalletStartUM.FeatureItem( + iconResId = R.drawable.ic_stack_fill_new_16, + text = resourceReference(R.string.welcome_create_wallet_feature_assets), + ), + ) + ) + assert(state.imageResId == R.drawable.img_mobile_wallet) + assert(state.shouldShowScanSecondaryButton.not()) + assert(state.primaryButtonText == resourceReference(R.string.welcome_create_wallet_mobile_title)) + assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_use_hardware_title)) + assert(state.otherMethodDescription == resourceReference(R.string.welcome_create_wallet_use_hardware_description)) + assert(state.isScanInProgress.not()) + } + + private fun createModel( + testScope: TestScope, + mode: CreateWalletStartComponent.Mode, + paramsContainer: ParamsContainer = MutableParamsContainer( + value = CreateWalletStartComponent.Params(mode = mode) + ), + ): CreateWalletStartModel { + return CreateWalletStartModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + scanCardProcessor = scanCardProcessor, + cardSdkConfigRepository = cardSdkConfigRepository, + settingsRepository = settingsRepository, + appRouter = appRouter, + coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, + saveWalletUseCase = saveWalletUseCase, + isHotWalletCreationSupported = isHotWalletCreationSupported, + userWalletsListRepository = userWalletsListRepository, + uiMessageSender = uiMessageSender, + generateBuyTangemCardLinkUseCase = generateBuyTangemCardLinkUseCase, + urlOpener = urlOpener, + trackingContextProxy = trackingContextProxy, + analyticsEventHandler = analyticsEventHandler, + appsFlyerStore = appsFlyerStore, + onboardingV2FeatureToggles = onboardingV2FeatureToggles, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt index 8a3ef86745..b714716c54 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/OnboardingV2FeatureToggles.kt @@ -2,4 +2,5 @@ package com.tangem.features.onboarding.v2 interface OnboardingV2FeatureToggles { val isVisaOnboardingEnabled: Boolean + val isAddressSyncEnabled: Boolean } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt index 6e165ef059..24c5fa9bbb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/DefaultOnboardingV2FeatureToggles.kt @@ -9,4 +9,6 @@ internal class DefaultOnboardingV2FeatureToggles @Inject constructor( ) : OnboardingV2FeatureToggles { override val isVisaOnboardingEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.VISA_ONBOARDING_ENABLED) + override val isAddressSyncEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.ADDRESS_SYNC_ENABLED) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt new file mode 100644 index 0000000000..d097548938 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt @@ -0,0 +1,13 @@ +package com.tangem.features.onboarding.v2.addresssync + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface AddressSyncComponent : ComposableContentComponent { + + data class Params( + val data: Any, // TODO("Will be implemented during [REDACTED_TASK_KEY]") + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt new file mode 100644 index 0000000000..78d1c659fe --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -0,0 +1,39 @@ +package com.tangem.features.onboarding.v2.addresssync + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel +import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddressSyncComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: AddressSyncComponent.Params, +) : AppComponentContext by appComponentContext, AddressSyncComponent { + + private val model: AddressSyncModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + + AddressSyncContent(modifier = modifier, state = state) + + BackHandler(onBack = state.onBackClick) + } + + @AssistedFactory + interface Factory : AddressSyncComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddressSyncComponent.Params, + ): DefaultAddressSyncComponent + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt new file mode 100644 index 0000000000..a9f22b031e --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.onboarding.v2.addresssync.di + +import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent +import com.tangem.features.onboarding.v2.addresssync.DefaultAddressSyncComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface AddressSyncComponentModule { + + @Binds + @Singleton + fun bindAddressSyncComponentFactory(factory: DefaultAddressSyncComponent.Factory): AddressSyncComponent.Factory +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt new file mode 100644 index 0000000000..95549a70e6 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.onboarding.v2.addresssync.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface AddressSyncModelModule { + + @Binds + @IntoMap + @ClassKey(AddressSyncModel::class) + fun bindAddressSyncModel(model: AddressSyncModel): Model +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt new file mode 100644 index 0000000000..bc58a40b89 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt @@ -0,0 +1,5 @@ +package com.tangem.features.onboarding.v2.addresssync.entity + +internal data class AddressSyncUM( + val onBackClick: () -> Unit, +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt new file mode 100644 index 0000000000..b214cef29e --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -0,0 +1,22 @@ +package com.tangem.features.onboarding.v2.addresssync.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.features.onboarding.v2.addresssync.entity.AddressSyncUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ModelScoped +internal class AddressSyncModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + val uiState: StateFlow + field: MutableStateFlow = MutableStateFlow(getInitialState()) + + private fun getInitialState(): AddressSyncUM { + TODO("Will be implemented during [REDACTED_TASK_KEY]") + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt new file mode 100644 index 0000000000..40b0b158eb --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt @@ -0,0 +1,54 @@ +package com.tangem.features.onboarding.v2.addresssync.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.components.appbar.AppBarWithBackButton +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.onboarding.v2.addresssync.entity.AddressSyncUM + +@Composable +internal fun AddressSyncContent(state: AddressSyncUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background(color = TangemTheme.colors.background.secondary) + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithBackButton( + onBackClick = state.onBackClick, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = TangemTheme.dimens.spacing16) + .weight(1f), + ) {} + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AddressSyncContentPreview(@PreviewParameter(PreviewStateProvider::class) state: AddressSyncUM) { + TangemThemePreview { + AddressSyncContent(state = state) + } +} + +private class PreviewStateProvider : CollectionPreviewParameterProvider( + buildList { + TODO("Will be implemented during [REDACTED_TASK_KEY]") + }, +) \ No newline at end of file From d30f231520cc5ef1ef160f49039705c08b660840 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 13:10:18 +0400 Subject: [PATCH 083/206] Updated on 2026-08-14 --- .../tangem/common/constants/TestConstants.kt | 1 + .../com/tangem/scenarios/AccountsScenarios.kt | 102 +++++++ .../com/tangem/screens/DialogPageObject.kt | 5 + .../screens/WalletSettingsPageObject.kt | 12 + .../AccountDetailsPageObject.kt | 19 +- .../accounts/ArchivedAccountsPageObject.kt | 70 +++++ .../tests/accounts/AccountArchivationsTest.kt | 253 ++++++++++++++++++ .../com/tangem/tests/main/HideTokenTest.kt | 10 +- .../sdk/mocks/content/WalletMockContent.kt | 101 ++++++- .../tangem/common/ui/account/AccountRow.kt | 5 + .../ui/test/AccountDetailsScreenTestTags.kt | 5 - .../ui/test/WalletSettingsScreenTestTags.kt | 1 + .../accounts/AccountDetailsScreenTestTags.kt | 9 + .../accounts/AccountInfoEditScreenTestTags.kt | 10 + .../ui/test/accounts/AccountRowTestTags.kt | 7 + .../ArchivedAccountsScreenTestTags.kt | 8 + .../archived/ui/ArchivedAccountListContent.kt | 8 +- .../createedit/ui/AccountCreateEditContent.kt | 8 +- .../details/ui/AccountDetailsContent.kt | 11 +- 19 files changed, 613 insertions(+), 32 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt rename app/src/androidTest/kotlin/com/tangem/screens/{ => accounts}/AccountDetailsPageObject.kt (58%) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/accounts/ArchivedAccountsPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/AccountDetailsScreenTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountDetailsScreenTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountInfoEditScreenTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountRowTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/accounts/ArchivedAccountsScreenTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index 8ad9db91da..4b2d416965 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -43,6 +43,7 @@ object TestConstants { const val ALLURE_LABEL_VALUE = "Kaspresso" const val USER_TOKENS_API_SCENARIO = "user_tokens_api" + const val REFERRAL_API_SCENARIO = "referral_api" const val QUOTES_API_SCENARIO = "quotes_api" const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk" diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt new file mode 100644 index 0000000000..7320642036 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/AccountsScenarios.kt @@ -0,0 +1,102 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.screens.accounts.onAccountDetailsScreen +import com.tangem.screens.accounts.onArchivedAccountsScreen +import com.tangem.screens.onDetailsScreen +import com.tangem.screens.onDialog +import com.tangem.screens.onMainScreenTopBar +import com.tangem.screens.onWalletSettingsScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.openWalletSettingsScreen() { + step("Open 'Wallet details' screen") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + step("Open 'Wallet settings' screen") { + onDetailsScreen { walletNameButton.clickWithAssertion() } + } +} + +fun BaseTestCase.openAccountDetails(accountName: String) { + step("Click on account: '$accountName'") { + onWalletSettingsScreen { accountItem(accountName).clickWithAssertion() } + } + step("Assert 'Account details' screen is displayed") { + onAccountDetailsScreen { screenContainer.assertIsDisplayed() } + } +} + +fun BaseTestCase.archiveAccount() { + step("Assert 'Archive' button is displayed") { + onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() } + } + step("Click on 'Archive' button") { + onAccountDetailsScreen { archiveAccountButton.clickWithAssertion() } + } + step("Confirm archivation in dialog") { + onDialog { archiveButton.clickWithAssertion() } + } +} + +fun BaseTestCase.assertArchiveConfirmationDialog() { + step("Assert confirmation dialog is displayed") { + onDialog { dialogContainer.assertIsDisplayed() } + } + step("Assert confirmation dialog has 'Cancel' button") { + onDialog { cancelButton.assertIsDisplayed() } + } + step("Assert confirmation dialog has 'Archive' button") { + onDialog { archiveButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.assertErrorDialog(expectedTitle: String, expectedMessage: String) { + step("Assert error dialog is displayed") { + onDialog { dialogContainer.assertIsDisplayed() } + } + step("Assert error dialog has proper title") { + onDialog { + title.assertTextContains(expectedTitle) + } + } + step("Assert error dialog has explanatory text") { + onDialog { + text.assertTextContains(expectedMessage) + } + } + step("Assert error dialog has 'OK' button") { + onDialog { okButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.dismissErrorDialog() { + step("Dismiss error dialog by clicking 'Ok' button") { + onDialog { okButton.clickWithAssertion() } + } +} + +fun BaseTestCase.openArchivedAccountsScreen() { + step("Click on 'Archived accounts' button") { + onWalletSettingsScreen { openArchivedAccountsButton.clickWithAssertion() } + } +} + +fun BaseTestCase.assertArchivedAccountIsDisplayed(accountName: String) { + step("Assert archived account with name '$accountName' is displayed") { + onArchivedAccountsScreen { + findArchivedAccountItemByName(accountName) + .container.assertIsDisplayed() + } + } +} + +fun BaseTestCase.restoreArchivedAccount(accountName: String) { + step("Restore account with name '$accountName'") { + onArchivedAccountsScreen { + findArchivedAccountItemByName(accountName) + .restoreButton.clickWithAssertion() + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index ff268b8969..87e72b5b05 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -40,6 +40,11 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasText(getResourceString(R.string.common_confirm)) } + val archiveButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.account_details_archive_action)) + } + val continueButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(R.string.common_continue)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt index db6413a7f2..5b84dda406 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/WalletSettingsPageObject.kt @@ -38,6 +38,18 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi hasText(getResourceString(R.string.settings_forget_wallet)) } + val accountsListContainer: KNode = walletSettingsItem.child { + hasTestTag(WalletSettingsScreenTestTags.ACCOUNTS_CONTAINER) + } + + val addAccountButton: KNode = walletSettingsItem.child { + hasText(getResourceString(R.string.account_form_create_button)) + } + + val openArchivedAccountsButton: KNode = walletSettingsItem.child { + hasText(getResourceString(R.string.account_archived_accounts)) + } + fun accountItem(accountName: String): KNode = walletSettingsItem.child { hasTestTag(WalletSettingsScreenTestTags.USER_ACCOUNT_ITEM) hasAnyDescendant(withText(accountName)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AccountDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/accounts/AccountDetailsPageObject.kt similarity index 58% rename from app/src/androidTest/kotlin/com/tangem/screens/AccountDetailsPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/accounts/AccountDetailsPageObject.kt index b347ee4785..99d9841489 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/AccountDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/accounts/AccountDetailsPageObject.kt @@ -1,8 +1,8 @@ -package com.tangem.screens +package com.tangem.screens.accounts import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase -import com.tangem.core.ui.test.AccountDetailsScreenTestTags +import com.tangem.core.ui.test.accounts.AccountDetailsScreenTestTags import com.tangem.core.ui.test.TopAppBarTestTags import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen @@ -11,6 +11,10 @@ import io.github.kakaocup.compose.node.element.KNode class AccountDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { + val screenContainer: KNode = child { + hasTestTag(AccountDetailsScreenTestTags.ACCOUNT_DETAILS_CONTAINER) + } + val topAppBarBackButton: KNode = child { hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) } @@ -18,7 +22,16 @@ class AccountDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi val manageTokensButton: KNode = child { hasTestTag(AccountDetailsScreenTestTags.MANAGE_TOKENS_BUTTON) } + + val archiveAccountButton: KNode = child { + hasTestTag(AccountDetailsScreenTestTags.ARCHIVE_ACCOUNT_BUTTON) + } + + val editAccountButton: KNode = child { + hasTestTag(AccountDetailsScreenTestTags.EDIT_ACCOUNT_BUTTON) + } + } -internal fun BaseTestCase.onAccountDetails(function: AccountDetailsPageObject.() -> Unit) = +internal fun BaseTestCase.onAccountDetailsScreen(function: AccountDetailsPageObject.() -> Unit) = onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/accounts/ArchivedAccountsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/accounts/ArchivedAccountsPageObject.kt new file mode 100644 index 0000000000..5fd1d2cae7 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/accounts/ArchivedAccountsPageObject.kt @@ -0,0 +1,70 @@ +package com.tangem.screens.accounts + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.R +import com.tangem.core.ui.test.TopAppBarTestTags +import com.tangem.core.ui.test.accounts.AccountRowTestTags +import com.tangem.core.ui.test.accounts.ArchivedAccountsScreenTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText + +class ArchivedAccountsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val screenContainer: KNode = child { + hasTestTag(ArchivedAccountsScreenTestTags.ARCHIVED_ACCOUNTS_SCREEN_CONTAINER) + } + + val topAppBarBackButton: KNode = child { + hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) + } + + val topAppBarTitle: KNode = child { + hasTestTag(TopAppBarTestTags.TITLE) + hasText(getResourceString(R.string.account_archived_accounts)) + useUnmergedTree = true + } + + /** + * Returns a composite handle for a single archived account row, scoped by account name. + * All sub-elements (icon, title, tokens, networks, restore button) are children of this row. + */ + fun findArchivedAccountItemByName(accountName: String): ArchivedAccountRow { + val container: KNode = child { + hasTestTag(ArchivedAccountsScreenTestTags.ARCHIVED_ACCOUNT_ITEM) + hasAnyDescendant(withText(accountName)) + useUnmergedTree = true + } + return ArchivedAccountRow(container) + } + + class ArchivedAccountRow(val container: KNode) { + + val icon: KNode = container.child { + hasTestTag(AccountRowTestTags.ICON) + useUnmergedTree = true + } + + val title: KNode = container.child { + hasTestTag(AccountRowTestTags.TITLE) + useUnmergedTree = true + } + + val subtitle: KNode = container.child { + hasTestTag(AccountRowTestTags.SUBTITLE) + useUnmergedTree = true + } + + val restoreButton: KNode = container.child { + hasTestTag(ArchivedAccountsScreenTestTags.RESTORE_BUTTON) + useUnmergedTree = true + } + } +} + +internal fun BaseTestCase.onArchivedAccountsScreen(function: ArchivedAccountsPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt new file mode 100644 index 0000000000..70e4d5d02a --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/accounts/AccountArchivationsTest.kt @@ -0,0 +1,253 @@ +package com.tangem.tests.accounts + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.REFERRAL_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.* +import com.tangem.screens.accounts.onAccountDetailsScreen +import com.tangem.screens.accounts.onArchivedAccountsScreen +import com.tangem.screens.onDialog +import com.tangem.screens.onWalletSettingsScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class AccountArchivationsTest : BaseTestCase() { + + private val userTokensScenario = USER_TOKENS_API_SCENARIO + private val referralScenario = REFERRAL_API_SCENARIO + + @Test + @AllureId("5979") + @DisplayName("Accounts: Verify main account archivation button not available") + fun mainAccountArchivationAttemptTest() { + val mainAccountName = "Main account" + + setupHooks().run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open wallet account with name $mainAccountName") { openAccountDetails(mainAccountName) } + step("Assert archive button is NOT displayed") { + onAccountDetailsScreen { archiveAccountButton.assertDoesNotExist() } + } + } + } + + @Test + @AllureId("5974") + @DisplayName("Accounts: archive a non-main account") + fun archiveSuccessfullyAccountTest() { + val accountToArchiveName = "Account 2" + val userAccountsState = "TwoAccountsArchivable" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open wallet account with name $accountToArchiveName") { + openAccountDetails(accountToArchiveName) + } + + step("Assert 'Archive' button is displayed") { + onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() } + } + step("Click on 'Archive' button") { + onAccountDetailsScreen { archiveAccountButton.clickWithAssertion() } + } + step("Verify 'Archive confirmation' dialog appeared with all elements") { + assertArchiveConfirmationDialog() + } + step("Click 'Archive' in confirmation menu") { + onDialog { archiveButton.clickWithAssertion() } + } + + step("Verify app returned to 'Wallet settings' screen") { + onWalletSettingsScreen { addAccountButton.assertIsDisplayed() } + } + step("Verify archived account '$accountToArchiveName' is no longer listed") { + onWalletSettingsScreen { + accountItem(accountToArchiveName).assertDoesNotExist() + } + } + } + } + + @Test + @AllureId("6844") + @DisplayName("Accounts: account archivation error on UI") + fun archiveAccountErrorTest() { + val accountToArchiveName = "Account 2" + val userAccountsState = "TwoAccountsArchivable" + val userAccountsErrorState = "AccountsPutError" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open wallet account with name $accountToArchiveName") { + openAccountDetails(accountToArchiveName) + } + + step("Forcing network error scenario") { + setWireMockScenarioState(userTokensScenario, userAccountsErrorState) + } + step("Attempt to archive the account") { archiveAccount() } + step("Assert error dialog details") { + assertErrorDialog( + expectedTitle = getResourceString(R.string.common_something_went_wrong), + expectedMessage = getResourceString(R.string.account_generic_error_dialog_message), + ) + } + } + } + + @Test + @AllureId("5981") + @DisplayName("Accounts: archive account with referral program error") + fun archiveAccountReferralErrorTest() { + val accountToArchiveName = "Account 2" + val userAccountsState = "TwoAccountsArchivableAndReferral" + val referralActiveState = "Participating" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsState) + setWireMockScenarioState(referralScenario, referralActiveState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + resetWireMockScenarioState(referralScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open wallet account with name $accountToArchiveName") { + openAccountDetails(accountToArchiveName) + } + step("Attempt to archive the account") { archiveAccount() } + + step("Assert error dialog details") { + assertErrorDialog( + expectedTitle = getResourceString(R.string.account_could_not_archive_referral_program_title), + expectedMessage = getResourceString(R.string.account_could_not_archive_referral_program_message), + ) + } + step("Dismiss error dialog") { dismissErrorDialog() } + step("Assert 'Archive' button is still visible after error") { + onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() } + } + } + } + + @Test + @AllureId("5976") + @DisplayName("Accounts: restore an archived account") + fun restoreArchivedAccountTest() { + val archivedAccountName = "Account 3" + val userAccountsInitialState = "TwoAccountsWithArchivedAccounts" + val userAccountsAfterArchivationState = "ReadyToRestore" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsInitialState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open 'Archived accounts' screen") { openArchivedAccountsScreen() } + step("Verify archived wallet account with name '$archivedAccountName' is present") { + assertArchivedAccountIsDisplayed(archivedAccountName) + } + + step("Switch WireMock to '$userAccountsAfterArchivationState' users scenario state") { + setWireMockScenarioState(userTokensScenario, userAccountsAfterArchivationState) + } + step("Restore account with name '$archivedAccountName'") { + restoreArchivedAccount(archivedAccountName) + } + + step("Assert 'Wallet settings' screen is displayed") { + onWalletSettingsScreen { addAccountButton.assertIsDisplayed() } + } + step("Assert restored account '$archivedAccountName' appears in 'Active accounts' list") { + onWalletSettingsScreen { accountItem(archivedAccountName).assertIsDisplayed() } + } + } + } + + @Test + @AllureId("7962") + @DisplayName("Accounts: restore archived account error") + fun restoreArchivedAccountErrorTest() { + val archivedAccountName = "Account 3" + val userAccountsInitialState = "TwoAccountsWithArchivedAccounts" + val userAccountsRestorationErrorState = "AccountsPutError" + + setupHooks( + additionalBeforeSection = { + setWireMockScenarioState(userTokensScenario, userAccountsInitialState) + }, + additionalAfterSection = { + resetWireMockScenarioState(userTokensScenario) + }, + ).run { + step("Open 'Main Screen'") { openMainScreen() } + step("Synchronize addresses") { synchronizeAddresses() } + step("Open wallet settings") { openWalletSettingsScreen() } + step("Open 'Archived accounts' screen") { openArchivedAccountsScreen() } + step("Verify archived wallet account with name '$archivedAccountName' is present") { + assertArchivedAccountIsDisplayed(archivedAccountName) + } + + step("Switch WireMock to '$userAccountsRestorationErrorState' user" + + "accounts scenario state to simulate restoration failure") { + setWireMockScenarioState(userTokensScenario, userAccountsRestorationErrorState) + } + step("Attempt restore account with name '$archivedAccountName'") { + restoreArchivedAccount(archivedAccountName) + } + + step("Assert error dialog details") { + assertErrorDialog( + expectedTitle = getResourceString(R.string.common_something_went_wrong), + expectedMessage = getResourceString(R.string.account_generic_error_dialog_message), + ) + } + step("Dismiss error dialog") { dismissErrorDialog() } + + step("Assert archived account '$archivedAccountName' is still in archived list") { + onArchivedAccountsScreen { + findArchivedAccountItemByName(archivedAccountName) + .container.assertIsDisplayed() + } + } + } + } +} diff --git a/app/src/androidTest/kotlin/com/tangem/tests/main/HideTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/main/HideTokenTest.kt index a69ee3c9f4..a576c9e327 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/main/HideTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/main/HideTokenTest.kt @@ -9,6 +9,7 @@ import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.* +import com.tangem.screens.accounts.onAccountDetailsScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName @@ -93,7 +94,7 @@ class HideTokenTest : BaseTestCase() { onWalletSettingsScreen { accountItem(accountName).performClick() } } step("Click on 'Manage tokens' button") { - onAccountDetails { manageTokensButton.performClick() } + onAccountDetailsScreen { manageTokensButton.performClick() } } step("Click on token: '$tokenTitle'") { onManageTokensScreen { tokenItem(tokenTitle).performClick() } @@ -118,7 +119,7 @@ class HideTokenTest : BaseTestCase() { } step("Click on 'Account details' screen 'Back' button") { waitForIdle() - onAccountDetails { topAppBarBackButton.performClick() } + onAccountDetailsScreen { topAppBarBackButton.performClick() } } step("Click on 'Wallet settings' screen 'Back' button") { waitForIdle() @@ -162,7 +163,7 @@ class HideTokenTest : BaseTestCase() { onWalletSettingsScreen { accountItem(accountName).performClick() } } step("Click on 'Manage tokens' button") { - onAccountDetails { manageTokensButton.performClick() } + onAccountDetailsScreen { manageTokensButton.performClick() } } step("Click on token: '$tokenTitle'") { onManageTokensScreen { tokenItem(tokenTitle).performClick() } @@ -187,7 +188,7 @@ class HideTokenTest : BaseTestCase() { } step("Click on 'Account details' screen 'Back' button") { waitForIdle() - onAccountDetails { topAppBarBackButton.performClick() } + onAccountDetailsScreen { topAppBarBackButton.performClick() } } step("Click on 'Wallet settings' screen 'Back' button") { waitForIdle() @@ -308,5 +309,4 @@ class HideTokenTest : BaseTestCase() { } } } - } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index 382b50ad26..e6d872be54 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -24,6 +24,9 @@ import java.util.Date @Suppress("LargeClass") object WalletMockContent : MockContent { + private val secp256k1WalletPublicKey = + byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5) + private val primaryCard = PrimaryCard( cardId = "AC05000000086747", batchId = "AC05", @@ -100,7 +103,7 @@ object WalletMockContent : MockContent { ), wallets = listOf( CardDTO.Wallet( - publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + publicKey = secp256k1WalletPublicKey, chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), curve = EllipticCurve.Secp256k1, settings = CardWallet.Settings(isPermanent = false), @@ -117,11 +120,15 @@ object WalletMockContent : MockContent { publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), ), + DerivationPath("m/44'/60'/0'/0/1") to ExtendedPublicKey( + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + ), DerivationPath("m/44'/1'/0'/0/0") to ExtendedPublicKey( publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), - DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( // Tron Network publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), @@ -137,7 +144,7 @@ object WalletMockContent : MockContent { publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), - DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( + DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), @@ -151,7 +158,7 @@ object WalletMockContent : MockContent { ), ), extendedPublicKey = ExtendedPublicKey( - publicKey = byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), + publicKey = secp256k1WalletPublicKey, chainCode = byteArrayOf(80, 13, -8, -108, -35, 116, -92, 125, -65, -28, 85, 72, -113, 59, 83, 13, 5, -83, -102, 123, 124, -22, 94, 108, -71, 95, 65, -2, 38, 38, -108, 14), ), isImported = false, @@ -193,9 +200,7 @@ object WalletMockContent : MockContent { override val derivationTaskResponse = DerivationTaskResponse( entries = mapOf( - ByteArrayKey( - byteArrayOf(2, -109, 28, -27, -124, -58, -97, -61, 43, -84, 90, -9, -5, 4, 90, 17, 112, -125, -108, 44, 19, -79, -60, -23, 34, -20, -20, 61, 84, 113, 120, -90, -5), - ) + ByteArrayKey(secp256k1WalletPublicKey) to ExtendedPublicKeysMap( mapOf( @@ -220,6 +225,20 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/60'/0'/0/1") to ExtendedPublicKey( // eth (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/60'/0'/0/3") to ExtendedPublicKey( // eth (account 3) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/145'/0'/0/0") to ExtendedPublicKey( // bch publicKey = byteArrayOf(2, 38, -6, 92, -37, -91, -59, -108, -18, -119, -55, 41, 38, -33, 44, 59, 24, -79, -14, -38, -10, -123, 106, 56, 39, 8, 112, 29, -41, 99, 70, -104, -121), chainCode = byteArrayOf(105, -21, -61, -50, 68, -89, 119, 53, -96, -40, 119, 77, -122, 121, 16, 40, -50, -48, -105, -101, -74, -7, -94, -59, -90, 96, 59, 99, 43, -91, 115, -29), @@ -241,7 +260,14 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), - DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( // Tron (account 0) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/1'/0/0") to ExtendedPublicKey( // Tron (account 2) publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), depth = 0, @@ -269,6 +295,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/111111'/1'/0/0") to ExtendedPublicKey( // Kaspa (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/330'/0'/0/0") to ExtendedPublicKey( // Terra publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), @@ -305,7 +338,14 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), - DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana + DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana (account 1) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/501'/1'") to ExtendedPublicKey( // Solana (account 2) publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), depth = 0, @@ -386,6 +426,13 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/60'/0'/0/3") to ExtendedPublicKey( // eth + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), @@ -407,7 +454,30 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/111111'/1'/0/0") to ExtendedPublicKey( // Kaspa (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, + 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67,), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/818'/0'/0/0") to ExtendedPublicKey( // Vechain + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, + 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67,), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/0'/0/0") to ExtendedPublicKey( // Tron (account 1) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/195'/1'/0/0") to ExtendedPublicKey( // Tron (account 2) publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), depth = 0, @@ -436,9 +506,16 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), - DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana - publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), - chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana (account 1) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/501'/1'") to ExtendedPublicKey( // Solana (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), depth = 0, parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt index 8e755eb8bc..76873d6c56 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountRow.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.common.ui.R @@ -19,6 +20,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.accounts.AccountRowTestTags /** * Displays a row representing an account with an icon, title, and subtitle. @@ -54,6 +56,7 @@ fun AccountRow( name = title, icon = icon, size = AccountIconSize.Default, + modifier = Modifier.testTag(AccountRowTestTags.ICON), ) Column( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing2), @@ -72,6 +75,7 @@ fun AccountRow( @Composable private fun Title(title: TextReference) { Text( + modifier = Modifier.testTag(AccountRowTestTags.TITLE), text = title.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.primary1, @@ -83,6 +87,7 @@ private fun Title(title: TextReference) { @Composable private fun Subtitle(subtitle: TextReference) { Text( + modifier = Modifier.testTag(AccountRowTestTags.SUBTITLE), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.caption2, text = subtitle.resolveReference(), diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/AccountDetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/AccountDetailsScreenTestTags.kt deleted file mode 100644 index ccc4793a3c..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/test/AccountDetailsScreenTestTags.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.core.ui.test - -object AccountDetailsScreenTestTags { - const val MANAGE_TOKENS_BUTTON = "ACCOUNT_DETAILS_SCREEN_MANAGE_TOKENS_BUTTON" -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WalletSettingsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WalletSettingsScreenTestTags.kt index 6d178c0cd9..15b8847280 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/WalletSettingsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WalletSettingsScreenTestTags.kt @@ -2,6 +2,7 @@ package com.tangem.core.ui.test object WalletSettingsScreenTestTags { const val SCREEN_CONTAINER = "WALLET_SETTINGS_SCREEN_CONTAINER" + const val ACCOUNTS_CONTAINER = "WALLET_SETTINGS_SCREEN_ACCOUNTS_CONTAINER" const val SCREEN_ITEM = "WALLET_SETTINGS_SCREEN_ITEM" const val USER_ACCOUNT_ITEM = "WALLET_SETTINGS_USER_ACCOUNT_ITEM" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountDetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountDetailsScreenTestTags.kt new file mode 100644 index 0000000000..cd4c95dbda --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountDetailsScreenTestTags.kt @@ -0,0 +1,9 @@ +package com.tangem.core.ui.test.accounts + +object AccountDetailsScreenTestTags { + + const val ACCOUNT_DETAILS_CONTAINER = "ACCOUNT_DETAILS_SCREEN_CONTAINER" + const val MANAGE_TOKENS_BUTTON = "ACCOUNT_DETAILS_SCREEN_MANAGE_TOKENS_BUTTON" + const val EDIT_ACCOUNT_BUTTON = "ACCOUNT_DETAILS_SCREEN_EDIT_ACCOUNT_BUTTON" + const val ARCHIVE_ACCOUNT_BUTTON = "ACCOUNT_DETAILS_SCREEN_ARCHIVE_ACCOUNT_BUTTON" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountInfoEditScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountInfoEditScreenTestTags.kt new file mode 100644 index 0000000000..940f3eebe1 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountInfoEditScreenTestTags.kt @@ -0,0 +1,10 @@ +package com.tangem.core.ui.test.accounts + +object AccountInfoEditScreenTestTags { + const val ACCOUNT_DETAILS_CONTAINER = "ACCOUNT_INFO_EDIT_SCREEN_ACCOUNT_DETAILS_CONTAINER" + const val ADD_ACCOUNT_BUTTON = "ACCOUNT_INFO_EDIT_SCREEN_ADD_ACCOUNT_BUTTON" + const val COLOR_OPTION = "ACCOUNT_INFO_EDIT_SCREEN_ACCOUNT_INFO_COLOR_OPTION" + const val TYPE_OPTION = "ACCOUNT_INFO_EDIT_SCREEN_ACCOUNT_INFO_TYPE_OPTION" + const val SELECTED_ICON = "ACCOUNT_INFO_EDIT_SCREEN_SELECTED_ICON" + const val NAME_FIELD = "ACCOUNT_INFO_EDIT_SCREEN_ACCOUNT_INFO_NAME_FIELD" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountRowTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountRowTestTags.kt new file mode 100644 index 0000000000..680509ff25 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/AccountRowTestTags.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.test.accounts + +object AccountRowTestTags { + const val ICON = "ACCOUNT_ROW_ICON" + const val TITLE = "ACCOUNT_ROW_TITLE" + const val SUBTITLE = "ACCOUNT_ROW_SUBTITLE" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/accounts/ArchivedAccountsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/ArchivedAccountsScreenTestTags.kt new file mode 100644 index 0000000000..9bb6ef093f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/accounts/ArchivedAccountsScreenTestTags.kt @@ -0,0 +1,8 @@ +package com.tangem.core.ui.test.accounts + +object ArchivedAccountsScreenTestTags { + + const val ARCHIVED_ACCOUNTS_SCREEN_CONTAINER = "ARCHIVED_ACCOUNTS_LIST_CONTAINER" + const val ARCHIVED_ACCOUNT_ITEM = "ARCHIVED_ACCOUNTS_LIST_ARCHIVED_ACCOUNT_ITEM" + const val RESTORE_BUTTON = "ARCHIVED_ACCOUNTS_LIST_RESTORE_BUTTON" +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt index 782341c8e1..7f4ec9b4c5 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/archived/ui/ArchivedAccountListContent.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider @@ -27,6 +28,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.accounts.ArchivedAccountsScreenTestTags import com.tangem.features.account.archived.entity.AccountArchivedUM import com.tangem.features.account.archived.entity.ArchivedAccountUM import kotlinx.collections.immutable.toImmutableList @@ -102,7 +104,7 @@ private fun ArchiveAccountError(state: AccountArchivedUM.Error, modifier: Modifi @Composable private fun ArchiveAccountContent(state: AccountArchivedUM.Content, modifier: Modifier = Modifier) { - LazyColumn(modifier = modifier) { + LazyColumn(modifier = modifier.testTag(ArchivedAccountsScreenTestTags.ARCHIVED_ACCOUNTS_SCREEN_CONTAINER)) { itemsIndexed( items = state.accounts, key = { index, item -> item.accountId }, @@ -127,7 +129,8 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod modifier = modifier .fillMaxWidth() .clickable(enabled = !item.isLoading, onClick = item.onClick) - .padding(all = TangemTheme.dimens.spacing12), + .padding(all = TangemTheme.dimens.spacing12) + .testTag(ArchivedAccountsScreenTestTags.ARCHIVED_ACCOUNT_ITEM), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { @@ -151,6 +154,7 @@ private fun ArchivedAccountRow(item: ArchivedAccountUM, modifier: Modifier = Mod isLoading = item.isLoading, onClick = item.onClick, ), + modifier = Modifier.testTag(ArchivedAccountsScreenTestTags.RESTORE_BUTTON), ) } } diff --git a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt index 792db8d675..1dc4a29253 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/createedit/ui/AccountCreateEditContent.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -40,6 +41,7 @@ import com.tangem.core.ui.components.fields.AutoSizeTextField import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.accounts.AccountInfoEditScreenTestTags import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.features.account.createedit.entity.AccountCreateEditUM @@ -128,6 +130,8 @@ private fun AccountSummary(account: Account, isCreateMode: Boolean) { name = account.name.value, icon = account.portfolioIcon, size = AccountIconSize.Large, + modifier = Modifier + .testTag(AccountInfoEditScreenTestTags.SELECTED_ICON), ) Spacer(modifier = Modifier.height(24.dp)) @@ -159,7 +163,9 @@ private fun AccountSummary(account: Account, isCreateMode: Boolean) { account.onNameChange(newName) }, - textFieldModifier = Modifier.focusRequester(focusRequester), + textFieldModifier = Modifier + .focusRequester(focusRequester) + .testTag(AccountInfoEditScreenTestTags.NAME_FIELD), ) SpacerH(20.dp) } diff --git a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt index b8a0613c61..e764b00d99 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/details/ui/AccountDetailsContent.kt @@ -32,7 +32,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.AccountDetailsScreenTestTags +import com.tangem.core.ui.test.accounts.AccountDetailsScreenTestTags import com.tangem.features.account.details.entity.AccountDetailsUM @Composable @@ -42,7 +42,8 @@ internal fun AccountDetailsContent(state: AccountDetailsUM, modifier: Modifier = .background(color = TangemTheme.colors.background.secondary) .fillMaxSize() .imePadding() - .systemBarsPadding(), + .systemBarsPadding() + .testTag(AccountDetailsScreenTestTags.ACCOUNT_DETAILS_CONTAINER), horizontalAlignment = Alignment.CenterHorizontally, ) { AppBarWithBackButton( @@ -95,7 +96,8 @@ private fun ArchiveAccountRow(state: AccountDetailsUM.ArchiveMode.Available) { .clip(RoundedCornerShape(TangemTheme.dimens.radius12)) .background(TangemTheme.colors.background.primary) .clickable(enabled = !state.isLoading, onClick = state.onArchiveAccountClick) - .padding(all = TangemTheme.dimens.spacing12), + .padding(all = TangemTheme.dimens.spacing12) + .testTag(AccountDetailsScreenTestTags.ARCHIVE_ACCOUNT_BUTTON), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { @@ -160,7 +162,8 @@ private fun AccountRow(state: AccountDetailsUM) { .clip(RoundedCornerShape(TangemTheme.dimens.radius12)) .background(TangemTheme.colors.background.primary) .clickable(onClick = state.onAccountEditClick) - .padding(all = TangemTheme.dimens.spacing12), + .padding(all = TangemTheme.dimens.spacing12) + .testTag(AccountDetailsScreenTestTags.EDIT_ACCOUNT_BUTTON), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), ) { From 333640b3a759dba4244de0c21ea8529258bc54b9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 11:50:28 +0100 Subject: [PATCH 084/206] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 11 +- .../com/tangem/common/routing/AppRoute.kt | 4 +- .../CreateWalletStartModel.kt | 5 +- .../CreateWalletStartModelTest.kt | 18 ++- .../v2/entry/OnboardingEntryComponent.kt | 1 + features/onboarding-v2/impl/build.gradle.kts | 12 ++ .../v2/addresssync/AddressSyncComponent.kt | 10 +- .../DefaultAddressSyncComponent.kt | 141 +++++++++++++++--- .../di/AddressSyncComponentModule.kt | 18 --- .../v2/addresssync/entity/AddressSyncUM.kt | 5 - .../v2/addresssync/model/AddressSyncIntent.kt | 8 + .../v2/addresssync/model/AddressSyncModel.kt | 60 +++++++- .../addresssync/navigation/AddressSyncStep.kt | 7 + .../v2/addresssync/ui/AddressSyncContent.kt | 38 ++--- .../entry/impl/model/OnboardingEntryModel.kt | 1 + .../api/OnboardingMultiWalletComponent.kt | 1 + .../DefaultOnboardingMultiWalletComponent.kt | 13 +- .../model/MultiWalletFinalizeModel.kt | 1 + .../impl/model/OnboardingMultiWalletModel.kt | 6 +- .../impl/model/OnboardingMultiWalletState.kt | 1 + .../v2/multiwallet/impl/model/Utils.kt | 1 + .../addresssync/model/AddressSyncModelTest.kt | 134 +++++++++++++++++ 22 files changed, 393 insertions(+), 103 deletions(-) delete mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt delete mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt create mode 100644 features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 72e8f32bb6..743496098a 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -25,7 +25,6 @@ import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensMode import com.tangem.features.managetokens.component.ManageTokensSource import com.tangem.features.nft.component.NFTComponent -import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent import com.tangem.features.onramp.component.* import com.tangem.features.pushnotifications.api.PushNotificationsComponent @@ -112,7 +111,6 @@ internal class ChildFactory @Inject constructor( private val kycComponentFactory: KycComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, private val feedEntryComponentFactory: FeedEntryComponent.Factory, - private val addressSyncComponentFactory: AddressSyncComponent.Factory, ) { @Suppress("LongMethod", "CyclomaticComplexMethod") @@ -262,6 +260,8 @@ internal class ChildFactory @Inject constructor( OnboardingEntryComponent.Mode.ContinueFinalize is AppRoute.Onboarding.Mode.UpgradeHotWallet -> OnboardingEntryComponent.Mode.UpgradeHotWallet(mode.userWalletId) + is AppRoute.Onboarding.Mode.AddressSync -> + OnboardingEntryComponent.Mode.AddressSync }, ), componentFactory = onboardingEntryComponentFactory, @@ -694,13 +694,6 @@ internal class ChildFactory @Inject constructor( componentFactory = feedEntryComponentFactory, ) } - is AppRoute.AddressSync -> { - createComponentChild( - context = context, - params = AddressSyncComponent.Params(data = Unit), - componentFactory = addressSyncComponentFactory, - ) - } } } } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index fd3d57f697..165823622e 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -330,6 +330,7 @@ sealed class AppRoute(val path: String) : Route { data object RecreateWalletTwin : Mode() // reset twins data object ContinueFinalize : Mode() // continue finalize process (unfinished backup dialog) data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() // upgrade hot wallet + data object AddressSync : Mode() } } @@ -484,7 +485,4 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId") - - @Serializable - data object AddressSync : AppRoute(path = "/address_sync") } \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 4344425538..b438774c00 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -237,7 +237,10 @@ internal class CreateWalletStartModel @Inject constructor( ifRight = { setLoading(false) val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { - AppRoute.AddressSync + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync + ) } else { AppRoute.Wallet } diff --git a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt index 2d780979b5..bd7640bef5 100644 --- a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt +++ b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt @@ -531,7 +531,17 @@ internal class CreateWalletStartModelTest { model.uiState.value.onScanClick.invoke() advanceUntilIdle() - verify { appRouter.replaceAll(routes = arrayOf(AppRoute.AddressSync), onComplete = any()) } + verify { + appRouter.replaceAll( + routes = arrayOf( + AppRoute.Onboarding( + scanResponse = testScanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync, + ) + ), + onComplete = any() + ) + } } @Test @@ -573,9 +583,9 @@ internal class CreateWalletStartModelTest { ) assert(state.imageResId == R.drawable.img_hardware_wallet) assert(state.shouldShowScanSecondaryButton) - assert(state.primaryButtonText == resourceReference(R.string.details_buy_wallet),) - assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_mobile_title),) - assert(state.otherMethodDescription == null,) + assert(state.primaryButtonText == resourceReference(R.string.details_buy_wallet)) + assert(state.otherMethodTitle == resourceReference(R.string.welcome_create_wallet_mobile_title)) + assert(state.otherMethodDescription == null) assert(state.isScanInProgress.not()) } diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt index a21785815f..d4776bf833 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt @@ -19,6 +19,7 @@ interface OnboardingEntryComponent : ComposableContentComponent { data object RecreateWalletTwin : Mode() data object ContinueFinalize : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() + data object AddressSync : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/build.gradle.kts b/features/onboarding-v2/impl/build.gradle.kts index 917e123120..f4022573af 100644 --- a/features/onboarding-v2/impl/build.gradle.kts +++ b/features/onboarding-v2/impl/build.gradle.kts @@ -11,11 +11,16 @@ android { namespace = "com.tangem.features.onboarding.v2.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.onboardingV2.api) implementation(projects.features.manageTokens.api) implementation(projects.features.biometry.api) + implementation(projects.features.pushNotifications.api) implementation(projects.features.hotWallet.api) implementation(projects.features.tokenRecieve.api) @@ -87,4 +92,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) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt index d097548938..d951a3e058 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt @@ -1,13 +1,5 @@ package com.tangem.features.onboarding.v2.addresssync -import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -interface AddressSyncComponent : ComposableContentComponent { - - data class Params( - val data: Any, // TODO("Will be implemented during [REDACTED_TASK_KEY]") - ) - - interface Factory : ComponentFactory -} \ No newline at end of file +interface AddressSyncComponent : ComposableContentComponent \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index 78d1c659fe..8357eb37d4 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -2,38 +2,141 @@ package com.tangem.features.onboarding.v2.addresssync import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.DelicateDecomposeApi +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.value.Value +import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.biometry.AskBiometryComponent +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncIntent import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel +import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncContent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.tangem.features.pushnotifications.api.PushNotificationsComponent +import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks +import com.tangem.features.pushnotifications.api.PushNotificationsParams -internal class DefaultAddressSyncComponent @AssistedInject constructor( - @Assisted appComponentContext: AppComponentContext, - @Assisted params: AddressSyncComponent.Params, +@OptIn(DelicateDecomposeApi::class) +internal class DefaultAddressSyncComponent( + appComponentContext: AppComponentContext, + private val askBiometryComponentFactory: AskBiometryComponent.Factory, + private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, ) : AppComponentContext by appComponentContext, AddressSyncComponent { - private val model: AddressSyncModel = getOrCreateModel(params) + private val model: AddressSyncModel = getOrCreateModel() + + private val childStack: Value> = + childStack( + key = "innerStack", + source = model.stackNavigation, + serializer = null, + initialConfiguration = AddressSyncStep.ASK_BIOMETRY, + handleBackButton = true, + childFactory = { configuration, factoryContext -> + createChild( + step = configuration, + childContext = childByContext(factoryContext), + ) + }, + ) @Composable override fun Content(modifier: Modifier) { - val state by model.uiState.collectAsStateWithLifecycle() + AddressSyncContent( + modifier = modifier, + childContent = { + Children( + stack = childStack + ) { + it.instance.Content(modifier = modifier) + } + } + ) - AddressSyncContent(modifier = modifier, state = state) - - BackHandler(onBack = state.onBackClick) + BackHandler { + model.onIntent(AddressSyncIntent.Back) + } } - @AssistedFactory - interface Factory : AddressSyncComponent.Factory { - override fun create( - context: AppComponentContext, - params: AddressSyncComponent.Params, - ): DefaultAddressSyncComponent + private fun createChild( + step: AddressSyncStep, + childContext: AppComponentContext, + ): ComposableContentComponent { + return when (step) { + AddressSyncStep.ASK_BIOMETRY -> createAskBiometryComponent(childContext) + AddressSyncStep.ASK_NOTIFICATIONS -> createPushNotificationComponent(childContext) + AddressSyncStep.ADDRESS_SYNC -> TODO("Will be implemented during [REDACTED_TASK_KEY]") + } + } + + private fun createAskBiometryComponent(childContext: AppComponentContext): AskBiometryComponent { + return askBiometryComponentFactory.create( + context = childContext, + params = AskBiometryComponent.Params( + isBottomSheetVariant = false, + modelCallbacks = object : AskBiometryComponent.ModelCallbacks { + override fun onAllowed() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ASK_NOTIFICATIONS, + replace = true + ) + ) + } + + override fun onDenied() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ASK_NOTIFICATIONS, + replace = false + ) + ) + } + } + ) + ) + } + + private fun createPushNotificationComponent(childContext: AppComponentContext): PushNotificationsComponent { + return pushNotificationsComponentFactory.create( + context = childContext, + params = PushNotificationsParams( + modelCallbacks = object : PushNotificationsModelCallbacks { + override fun onAllowSystemPermission() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ADDRESS_SYNC, + replace = true + ) + ) + } + + override fun onDenySystemPermission() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ADDRESS_SYNC, + replace = false + ) + ) + } + + override fun onDismiss() { + model.onIntent( + AddressSyncIntent.Next( + step = AddressSyncStep.ADDRESS_SYNC, + replace = false + ) + ) + } + }, + source = AppRoute.PushNotification.Source.Onboarding, + ) + ) } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt deleted file mode 100644 index a9f22b031e..0000000000 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/di/AddressSyncComponentModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.features.onboarding.v2.addresssync.di - -import com.tangem.features.onboarding.v2.addresssync.AddressSyncComponent -import com.tangem.features.onboarding.v2.addresssync.DefaultAddressSyncComponent -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface AddressSyncComponentModule { - - @Binds - @Singleton - fun bindAddressSyncComponentFactory(factory: DefaultAddressSyncComponent.Factory): AddressSyncComponent.Factory -} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt deleted file mode 100644 index bc58a40b89..0000000000 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/entity/AddressSyncUM.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.onboarding.v2.addresssync.entity - -internal data class AddressSyncUM( - val onBackClick: () -> Unit, -) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt new file mode 100644 index 0000000000..47ecbbb69d --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt @@ -0,0 +1,8 @@ +package com.tangem.features.onboarding.v2.addresssync.model + +import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep + +sealed interface AddressSyncIntent { + data class Next(val step: AddressSyncStep, val replace: Boolean) : AddressSyncIntent + data object Back : AddressSyncIntent +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt index b214cef29e..773be2996e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -1,22 +1,68 @@ package com.tangem.features.onboarding.v2.addresssync.model +import com.arkivanov.decompose.DelicateDecomposeApi +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.push +import com.arkivanov.decompose.router.stack.replaceCurrent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model -import com.tangem.features.onboarding.v2.addresssync.entity.AddressSyncUM +import com.tangem.domain.settings.CanUseBiometryUseCase +import com.tangem.domain.settings.ShouldAskPermissionUseCase +import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import javax.inject.Inject +@OptIn(DelicateDecomposeApi::class) @ModelScoped internal class AddressSyncModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase, + private val canUseBiometryUseCase: CanUseBiometryUseCase, + private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, ) : Model() { - val uiState: StateFlow - field: MutableStateFlow = MutableStateFlow(getInitialState()) + val stackNavigation = StackNavigation() - private fun getInitialState(): AddressSyncUM { - TODO("Will be implemented during [REDACTED_TASK_KEY]") + fun onIntent(intent: AddressSyncIntent) { + when (intent) { + is AddressSyncIntent.Next -> nextScreen(intent) + AddressSyncIntent.Back -> goBack() + } + } + + private fun nextScreen(next: AddressSyncIntent.Next) { + val (nextStep, replace) = next + if (replace) { + stackNavigation.replaceCurrent(configuration = nextStep) + } else { + stackNavigation.push(configuration = nextStep) + } + modelScope.launch { trySkippingScreen(next) } + } + + private suspend fun trySkippingScreen(next: AddressSyncIntent.Next) { + when (next.step) { + AddressSyncStep.ASK_BIOMETRY -> { + val showBiometry = canUseBiometryUseCase.strict() && shouldShowAskBiometryUseCase() + if (showBiometry.not()) { + nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, replace = true)) + } + } + AddressSyncStep.ASK_NOTIFICATIONS -> { + val showNotification = shouldAskPermissionUseCase(PUSH_PERMISSION) + if (showNotification.not()) { + nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ADDRESS_SYNC, replace = true)) + } + } + AddressSyncStep.ADDRESS_SYNC -> Unit + } + } + + private fun goBack() { + stackNavigation.pop() } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt new file mode 100644 index 0000000000..edd056ce90 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt @@ -0,0 +1,7 @@ +package com.tangem.features.onboarding.v2.addresssync.navigation + +enum class AddressSyncStep { + ASK_BIOMETRY, + ASK_NOTIFICATIONS, + ADDRESS_SYNC, +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt index 40b0b158eb..3da4b6d12e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt @@ -2,20 +2,22 @@ package com.tangem.features.onboarding.v2.addresssync.ui import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.onboarding.v2.addresssync.entity.AddressSyncUM @Composable -internal fun AddressSyncContent(state: AddressSyncUM, modifier: Modifier = Modifier) { +internal fun AddressSyncContent( + modifier: Modifier = Modifier, + childContent: @Composable (Modifier) -> Unit = {}, +) { Column( modifier = modifier .background(color = TangemTheme.colors.background.secondary) @@ -24,31 +26,15 @@ internal fun AddressSyncContent(state: AddressSyncUM, modifier: Modifier = Modif .systemBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - AppBarWithBackButton( - onBackClick = state.onBackClick, - modifier = Modifier.height(TangemTheme.dimens.size56), - ) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = TangemTheme.dimens.spacing16) - .weight(1f), - ) {} + childContent(modifier) } } @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun AddressSyncContentPreview(@PreviewParameter(PreviewStateProvider::class) state: AddressSyncUM) { +private fun AddressSyncContentPreview() { TangemThemePreview { - AddressSyncContent(state = state) + AddressSyncContent() } -} - -private class PreviewStateProvider : CollectionPreviewParameterProvider( - buildList { - TODO("Will be implemented during [REDACTED_TASK_KEY]") - }, -) \ No newline at end of file +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index d3a679ae26..5747977a6d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -82,6 +82,7 @@ internal class OnboardingEntryModel @Inject constructor( is Mode.UpgradeHotWallet -> OnboardingMultiWalletComponent.Mode.UpgradeHotWallet( userWalletId = mode.userWalletId, ) + is Mode.AddressSync -> OnboardingMultiWalletComponent.Mode.AddressSync else -> error("Incorrect onboarding type") } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt index 268966cf50..9e4e023699 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt @@ -24,6 +24,7 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi data object AddBackup : Mode() data object ContinueFinalize : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() + data object AddressSync : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index 6526071a17..4fe733a371 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -25,8 +25,10 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerNavigation import com.tangem.core.decompose.navigation.inner.InnerNavigationState import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.biometry.AskBiometryComponent +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 import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.MultiWalletBackupComponent @@ -42,6 +44,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiW import com.tangem.features.onboarding.v2.multiwallet.impl.ui.OnboardingMultiWallet import com.tangem.features.onboarding.v2.multiwallet.impl.ui.WalletArtworksState import com.tangem.features.onboarding.v2.util.ResetCardsComponent +import com.tangem.features.pushnotifications.api.PushNotificationsComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -53,6 +56,8 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor @Assisted private val params: OnboardingMultiWalletComponent.Params, private val analyticsHandler: AnalyticsEventHandler, private val resetCardsComponentFactory: ResetCardsComponent.Factory, + private val askBiometryComponentFactory: AskBiometryComponent.Factory, + private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, ) : OnboardingMultiWalletComponent, AppComponentContext by context { private val model: OnboardingMultiWalletModel = getOrCreateModel(params) @@ -60,6 +65,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor private val artworksState = instanceKeeper.getOrCreateSimple(key = "artworksState") { MutableStateFlow( when (model.state.value.currentStep) { + AddressSync -> WalletArtworksState.Hidden UpgradeWallet -> WalletArtworksState.Folded CreateWallet -> WalletArtworksState.Folded ChooseBackupOption -> WalletArtworksState.Fan @@ -186,6 +192,11 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor onBack = { model.onBack() }, onEvent = ::handleFinalizeComponentEvent, ) + AddressSync -> DefaultAddressSyncComponent( + appComponentContext = childContext, + askBiometryComponentFactory = askBiometryComponentFactory, + pushNotificationsComponentFactory = pushNotificationsComponentFactory, + ) Done -> error("Unexpected Done state") } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index 61d26b089e..9dc1d725b0 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -265,6 +265,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( val userWallet = when (params.parentParams.mode) { OnboardingMultiWalletComponent.Mode.Onboarding, OnboardingMultiWalletComponent.Mode.ContinueFinalize, + OnboardingMultiWalletComponent.Mode.AddressSync, -> { saveWalletUseCase.invoke( userWallet = userWalletCreated.copy( diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt index d254fca350..cb903802ce 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -120,8 +120,12 @@ internal class OnboardingMultiWalletModel @Inject constructor( params.mode is OnboardingMultiWalletComponent.Mode.UpgradeHotWallet -> { OnboardingMultiWalletState.Step.UpgradeWallet } - params.mode == OnboardingMultiWalletComponent.Mode.ContinueFinalize -> + params.mode == OnboardingMultiWalletComponent.Mode.ContinueFinalize -> { OnboardingMultiWalletState.Step.Finalize + } + params.mode == OnboardingMultiWalletComponent.Mode.AddressSync -> { + OnboardingMultiWalletState.Step.AddressSync + } // Add backup button // Wallet1 without backup and userwallet's scanResponse doesn't contain primary card. card.wallets.isNotEmpty() && card.backupStatus == CardDTO.BackupStatus.NoBackup && diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt index d1fa192d94..7da46f2dfb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletState.kt @@ -38,6 +38,7 @@ data class OnboardingMultiWalletState( SeedPhrase, ScanPrimary, AddBackupDevice, + AddressSync, Finalize, Done, } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt index 9a7b2c1476..aff07ca039 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt @@ -15,4 +15,5 @@ fun screenTitleByStep(step: OnboardingMultiWalletState.Step): TextReference = wh OnboardingMultiWalletState.Step.Finalize -> resourceReference(R.string.onboarding_button_finalize_backup) OnboardingMultiWalletState.Step.Done -> resourceReference(R.string.common_done) OnboardingMultiWalletState.Step.UpgradeWallet -> resourceReference(R.string.common_tangem) + OnboardingMultiWalletState.Step.AddressSync -> TODO("Will be implemented during [REDACTED_TASK_KEY]") } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt new file mode 100644 index 0000000000..5f5797cc29 --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -0,0 +1,134 @@ +package com.tangem.features.onboarding.v2.addresssync.model + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.tangem.domain.settings.CanUseBiometryUseCase +import com.tangem.domain.settings.ShouldAskPermissionUseCase +import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class AddressSyncModelTest { + + private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase = mockk() + private val canUseBiometryUseCase: CanUseBiometryUseCase = mockk() + private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase = mockk() + + @BeforeEach + fun setUp() { + coEvery { canUseBiometryUseCase.strict() } returns false + coEvery { shouldShowAskBiometryUseCase() } returns false + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + } + + @Test + fun `GIVEN biometry allowed AND should show biometry WHEN Next ASK_BIOMETRY THEN ASK_BIOMETRY on top`() = runTest { + coEvery { canUseBiometryUseCase.strict() } returns true + coEvery { shouldShowAskBiometryUseCase() } returns true + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, replace = false)) + advanceUntilIdle() + + assert(stack == listOf(AddressSyncStep.ASK_BIOMETRY)) + } + + @Test + fun `GIVEN biometry skipped AND notifications required WHEN Next ASK_BIOMETRY THEN ASK_NOTIFICATIONS on top`() = + runTest { + coEvery { canUseBiometryUseCase.strict() } returns true + coEvery { shouldShowAskBiometryUseCase() } returns false + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns true + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, replace = false)) + advanceUntilIdle() + + assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS)) + } + + @Test + fun `GIVEN biometry skipped AND notifications skipped WHEN Next ASK_BIOMETRY THEN ADDRESS_SYNC on top`() = runTest { + coEvery { canUseBiometryUseCase.strict() } returns true + coEvery { shouldShowAskBiometryUseCase() } returns false + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, replace = false)) + advanceUntilIdle() + + assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC)) + } + + @Test + fun `GIVEN notifications required WHEN Next ASK_NOTIFICATIONS THEN ASK_NOTIFICATIONS on top`() = runTest { + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns true + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, replace = false)) + advanceUntilIdle() + + assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS)) + } + + @Test + fun `GIVEN notifications skipped WHEN Next ASK_NOTIFICATIONS THEN ADDRESS_SYNC on top`() = runTest { + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, replace = false)) + advanceUntilIdle() + + assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC)) + } + + private fun StackNavigation.trackStack(): List { + val tracked = mutableListOf() + subscribe { event -> + val newStack = event.transformer(tracked.toList()) + tracked.clear() + tracked.addAll(newStack) + } + return tracked + } + + private fun createModel(testScope: TestScope): AddressSyncModel { + return AddressSyncModel( + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + shouldShowAskBiometryUseCase = shouldShowAskBiometryUseCase, + canUseBiometryUseCase = canUseBiometryUseCase, + shouldAskPermissionUseCase = shouldAskPermissionUseCase, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } +} \ No newline at end of file From 40a5e13ad4762da7e1add13b8f1061f457c61d84 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 14:29:35 +0300 Subject: [PATCH 085/206] Updated on 2026-08-14 --- core/ui/build.gradle.kts | 72 + core/ui/ds-tokens | 1 + .../tangem/core/ui/res/generated/.tokens-hash | 1 + .../ui/res/generated/TangemDarkColorTokens.kt | 118 ++ .../ui/res/generated/TangemDimensionTokens.kt | 72 + .../res/generated/TangemLightColorTokens.kt | 118 ++ .../ui/res/generated/TangemOpacityTokens.kt | 22 + .../ui/res/generated/TangemShadowTokens.kt | 15 + .../res/generated/TangemTypographyTokens.kt | 88 + core/ui/token-gen/.gitignore | 1 + core/ui/token-gen/build-tokens.mjs | 532 +++++ core/ui/token-gen/package-lock.json | 1749 +++++++++++++++++ core/ui/token-gen/package.json | 13 + 13 files changed, 2802 insertions(+) create mode 160000 core/ui/ds-tokens create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDarkColorTokens.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimensionTokens.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemLightColorTokens.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemOpacityTokens.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemShadowTokens.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypographyTokens.kt create mode 100644 core/ui/token-gen/.gitignore create mode 100644 core/ui/token-gen/build-tokens.mjs create mode 100644 core/ui/token-gen/package-lock.json create mode 100644 core/ui/token-gen/package.json diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 87b86b8135..58c1e0cb0f 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -1,9 +1,71 @@ +import java.security.MessageDigest + plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) id("configuration") } +/** + * Verifies that generated Kotlin token files match the current ds-tokens submodule. + * If this fails, run: cd core/ui/token-gen && npm run build + */ +abstract class VerifyDesignTokensTask : DefaultTask() { + + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val tokensDir: DirectoryProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val hashFile: RegularFileProperty + + @get:OutputFile + abstract val stampFile: RegularFileProperty + + @TaskAction + fun verify() { + val hashFileValue = hashFile.get().asFile + require(hashFileValue.exists()) { + "Design tokens hash file not found: ${hashFileValue.absolutePath}\n" + + "Run the token generator: cd core/ui/token-gen && npm run build" + } + + val tokensDirValue = tokensDir.get().asFile + require(tokensDirValue.exists() && tokensDirValue.isDirectory) { + "ds-tokens submodule not found: ${tokensDirValue.absolutePath}\n" + + "Run: git submodule update --init --recursive" + } + + val digest = MessageDigest.getInstance("SHA-256") + val jsonFiles = tokensDirValue.walkTopDown() + .filter { it.isFile && it.extension == "json" } + .sortedBy { it.relativeTo(tokensDirValue).path } + .toList() + + val nul = byteArrayOf(0) + for (file in jsonFiles) { + digest.update(file.relativeTo(tokensDirValue).invariantSeparatorsPath.toByteArray()) + digest.update(nul) + digest.update(file.readBytes()) + digest.update(nul) + } + + val actual = digest.digest() + .joinToString("") { b: Byte -> b.toInt().and(0xFF).toString(16).padStart(2, '0') } + val expected = hashFileValue.readText().trim() + + require(actual == expected) { + "Design tokens are out of date!\n" + + " ds-tokens hash: $actual\n" + + " generated hash: $expected\n" + + "Run the token generator: cd core/ui/token-gen && npm run build" + } + + stampFile.get().asFile.writeText(actual) + } +} + android { namespace = "com.tangem.core.ui" @@ -15,6 +77,16 @@ android { } } +val verifyDesignTokens = tasks.register("verifyDesignTokens") { + tokensDir.set(file("ds-tokens/tokens")) + hashFile.set(file("src/main/java/com/tangem/core/ui/res/generated/.tokens-hash")) + stampFile.set(layout.buildDirectory.file("tokens-verified.stamp")) +} + +tasks.named("preBuild") { + dependsOn(verifyDesignTokens) +} + dependencies { /** Project - Domain */ implementation(projects.domain.appTheme.models) diff --git a/core/ui/ds-tokens b/core/ui/ds-tokens new file mode 160000 index 0000000000..f4d2156236 --- /dev/null +++ b/core/ui/ds-tokens @@ -0,0 +1 @@ +Subproject commit f4d2156236d7f77b61f26250351f6bfa72262a69 diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash new file mode 100644 index 0000000000..14287aaf78 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash @@ -0,0 +1 @@ +ddef7c44794c12a02c136fcc17f01fe51b6372d33a6a7eda4913d2d1b1f86203 diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDarkColorTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDarkColorTokens.kt new file mode 100644 index 0000000000..0f731be297 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDarkColorTokens.kt @@ -0,0 +1,118 @@ +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + * Theme: Dark + */ +internal class TangemDarkColorTokens { + // color.text + val colorTextPrimary = Color(0xFFFFFFFF) + val colorTextSecondary = Color(0x99FFFFFF) + val colorTextTertiary = Color(0x4DFFFFFF) + val colorTextBrand = Color(0xFF0090F9) + val colorTextStaticLightPrimary = Color(0xFF000000) + val colorTextStaticLightSecondary = Color(0x99000000) + val colorTextStaticLightTertiary = Color(0x66000000) + val colorTextStaticDarkPrimary = Color(0xFFFFFFFF) + val colorTextStaticDarkSecondary = Color(0x99FFFFFF) + val colorTextStaticDarkTertiary = Color(0x4DFFFFFF) + val colorTextInversePrimary = Color(0xFF0F0F0F) + val colorTextInverseSecondary = Color(0x990F0F0F) + val colorTextInverseTertiary = Color(0x660F0F0F) + val colorTextStatusSuccess = Color(0xFF2DAE3B) + val colorTextStatusError = Color(0xFFFF5E66) + val colorTextStatusWarning = Color(0xFFEFA210) + val colorTextStatusInfo = Color(0xFF109FF0) + val colorTextAccentBlue = Color(0xFF109FF0) + val colorTextAccentViolet = Color(0xFFB07BFD) + val colorTextAccentRed = Color(0xFFFF5E66) + val colorTextAccentOrange = Color(0xFFFA6931) + val colorTextAccentYellow = Color(0xFFEFA210) + val colorTextAccentGreen = Color(0xFF2DAE3B) + + // color.bg + val colorBgPrimary = Color(0xFF0F0F0F) + val colorBgSecondary = Color(0xFF1B1B1B) + val colorBgTertiary = Color(0xFF2C2C2C) + val colorBgBrand = Color(0xFF0090F9) + val colorBgInverse = Color(0xFFF4F4F4) + val colorBgBase = Color(0xFF000000) + val colorBgOpaquePrimary = Color(0x0DFFFFFF) + val colorBgOpaqueSecondary = Color(0x1AFFFFFF) + val colorBgStatusSuccess = Color(0xFF2DA30D) + val colorBgStatusError = Color(0xFFF25508) + val colorBgStatusWarning = Color(0xFFE68A03) + val colorBgStatusInfo = Color(0xFF0090F9) + val colorBgAccentBlue = Color(0xFF0090F9) + val colorBgAccentViolet = Color(0xFFA967FD) + val colorBgAccentRed = Color(0xFFFE4142) + val colorBgAccentOrange = Color(0xFFF25508) + val colorBgAccentYellow = Color(0xFFE68A03) + val colorBgAccentGreen = Color(0xFF2DA30D) + + // color.icon + val colorIconPrimary = Color(0xFFFFFFFF) + val colorIconSecondary = Color(0x99FFFFFF) + val colorIconTertiary = Color(0x4DFFFFFF) + val colorIconBrand = Color(0xFF0090F9) + val colorIconStaticLight = Color(0xFF000000) + val colorIconStaticDark = Color(0xFFFFFFFF) + val colorIconInverse = Color(0xFF0F0F0F) + val colorIconStatusSuccess = Color(0xFF2DAE3B) + val colorIconStatusError = Color(0xFFFA6931) + val colorIconStatusWarning = Color(0xFFEFA210) + val colorIconStatusInfo = Color(0xFF109FF0) + val colorIconAccentBlue = Color(0xFF109FF0) + val colorIconAccentViolet = Color(0xFFB07BFD) + val colorIconAccentRed = Color(0xFFFF5E66) + val colorIconAccentOrange = Color(0xFFFA6931) + val colorIconAccentYellow = Color(0xFFEFA210) + val colorIconAccentGreen = Color(0xFF2DAE3B) + + // color.border + val colorBorderPrimary = Color(0x0DFFFFFF) + val colorBorderSecondary = Color(0x1AFFFFFF) + val colorBorderTertiary = Color(0x33FFFFFF) + val colorBorderBrand = Color(0xFF0090F9) + val colorBorderInversePrimary = Color(0x0D000000) + val colorBorderInverseSecondary = Color(0x1A000000) + val colorBorderInverseTertiary = Color(0x33000000) + val colorBorderStatusSuccess = Color(0xFF2DAE3B) + val colorBorderStatusError = Color(0xFFFA6931) + val colorBorderStatusWarning = Color(0xFFEFA210) + val colorBorderStatusInfo = Color(0xFF109FF0) + val colorBorderAccentBlue = Color(0xFF109FF0) + val colorBorderAccentViolet = Color(0xFFB07BFD) + val colorBorderAccentRed = Color(0xFFFF5E66) + val colorBorderAccentOrange = Color(0xFFFA6931) + val colorBorderAccentYellow = Color(0xFFEFA210) + val colorBorderAccentGreen = Color(0xFF2DAE3B) + + // color.overlay + val colorOverlayModal = Color(0xCC000000) + + // color.interaction + val colorInteractionPress = Color(0x1AFFFFFF) + val colorInteractionPressStaticLight = Color(0x1A000000) + val colorInteractionPressStaticDark = Color(0x1AFFFFFF) + val colorInteractionPressInverse = Color(0x1A000000) + + // color.material + val colorMaterialTintGlass = Color(0x66181818) + val colorMaterialTintBlur = Color(0x00000000) + val colorMaterialTintSolid = Color(0x1AFFFFFF) + val colorMaterialFillGlass = Color(0x00000000) + val colorMaterialFillBlur = Color(0x1AFFFFFF) + val colorMaterialFillSolid = Color(0xE62C2C2C) + val colorMaterialLightenGlass = Color(0x33181818) + val colorMaterialLightenBlur = Color(0x00000000) + val colorMaterialLightenSolid = Color(0x00000000) + val colorMaterialSoftLightGlass = Color(0x1A000000) + val colorMaterialSoftLightBlur = Color(0x00000000) + val colorMaterialSoftLightSolid = Color(0x00000000) + val colorMaterialBorderStart = Color(0x33FFFFFF) + val colorMaterialBorderMid = Color(0x00FFFFFF) + val colorMaterialBorderEnd = Color(0x1AFFFFFF) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimensionTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimensionTokens.kt new file mode 100644 index 0000000000..dcf1be90a8 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimensionTokens.kt @@ -0,0 +1,72 @@ +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +internal class TangemDimensionTokens { + // blur + val blurSizeCard = 48.dp + val blurSizeButton = 32.dp + + // border-radius + val borderRadius100 = 8.dp + val borderRadius150 = 12.dp + val borderRadius200 = 16.dp + val borderRadius250 = 20.dp + val borderRadius300 = 24.dp + val borderRadius400 = 32.dp + val borderRadiusNone = 0.dp + val borderRadius075 = 6.dp + val borderRadius050 = 4.dp + val borderRadiusFull = 999.dp + + // border-width + val borderWidthNone = 0.dp + val borderWidthXs = 0.5.dp + val borderWidthSm = 1.dp + val borderWidthMd = 2.dp + val borderWidthLg = 4.dp + + // size + val size100 = 8.dp + val size125 = 10.dp + val size150 = 12.dp + val size200 = 16.dp + val size250 = 20.dp + val size300 = 24.dp + val size350 = 28.dp + val size400 = 32.dp + val size450 = 36.dp + val size500 = 40.dp + val size550 = 44.dp + val size600 = 48.dp + val size700 = 56.dp + val size800 = 64.dp + val size1000 = 80.dp + val size1100 = 88.dp + val size1200 = 96.dp + val size025 = 2.dp + val size050 = 4.dp + val sizeCardSm = 128.dp + + // spacing + val spacing100 = 8.dp + val spacing150 = 12.dp + val spacing200 = 16.dp + val spacing250 = 20.dp + val spacing300 = 24.dp + val spacing350 = 28.dp + val spacing400 = 32.dp + val spacing450 = 36.dp + val spacing500 = 40.dp + val spacing550 = 44.dp + val spacing600 = 48.dp + val spacing700 = 56.dp + val spacing800 = 64.dp + val spacing1000 = 80.dp + val spacing025 = 2.dp + val spacing050 = 4.dp + val spacingNone = 0.dp +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemLightColorTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemLightColorTokens.kt new file mode 100644 index 0000000000..75c0758b25 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemLightColorTokens.kt @@ -0,0 +1,118 @@ +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + * Theme: Light + */ +internal class TangemLightColorTokens { + // color.text + val colorTextPrimary = Color(0xFF0F0F0F) + val colorTextSecondary = Color(0x990F0F0F) + val colorTextTertiary = Color(0x660F0F0F) + val colorTextBrand = Color(0xFF0090F9) + val colorTextStaticLightPrimary = Color(0xFF000000) + val colorTextStaticLightSecondary = Color(0x99000000) + val colorTextStaticLightTertiary = Color(0x66000000) + val colorTextStaticDarkPrimary = Color(0xFFFFFFFF) + val colorTextStaticDarkSecondary = Color(0x99FFFFFF) + val colorTextStaticDarkTertiary = Color(0x4DFFFFFF) + val colorTextInversePrimary = Color(0xFFFFFFFF) + val colorTextInverseSecondary = Color(0x99FFFFFF) + val colorTextInverseTertiary = Color(0x4DFFFFFF) + val colorTextStatusSuccess = Color(0xFF2DA30D) + val colorTextStatusError = Color(0xFFFE4142) + val colorTextStatusWarning = Color(0xFFE68A03) + val colorTextStatusInfo = Color(0xFF0090F9) + val colorTextAccentBlue = Color(0xFF0090F9) + val colorTextAccentViolet = Color(0xFFA967FD) + val colorTextAccentRed = Color(0xFFFE4142) + val colorTextAccentOrange = Color(0xFFF25508) + val colorTextAccentYellow = Color(0xFFE68A03) + val colorTextAccentGreen = Color(0xFF2DA30D) + + // color.bg + val colorBgPrimary = Color(0xFFF4F4F4) + val colorBgSecondary = Color(0xFFFFFFFF) + val colorBgTertiary = Color(0xFFEAEAEA) + val colorBgBrand = Color(0xFF0090F9) + val colorBgInverse = Color(0xFFF4F4F4) + val colorBgBase = Color(0xFFF4F4F4) + val colorBgOpaquePrimary = Color(0x0DFFFFFF) + val colorBgOpaqueSecondary = Color(0x1AFFFFFF) + val colorBgStatusSuccess = Color(0xFF2DA30D) + val colorBgStatusError = Color(0xFFF25508) + val colorBgStatusWarning = Color(0xFFE68A03) + val colorBgStatusInfo = Color(0xFF0090F9) + val colorBgAccentBlue = Color(0xFF0090F9) + val colorBgAccentViolet = Color(0xFFA967FD) + val colorBgAccentRed = Color(0xFFFE4142) + val colorBgAccentOrange = Color(0xFFF25508) + val colorBgAccentYellow = Color(0xFFE68A03) + val colorBgAccentGreen = Color(0xFF2DA30D) + + // color.icon + val colorIconPrimary = Color(0xFF0F0F0F) + val colorIconSecondary = Color(0x990F0F0F) + val colorIconTertiary = Color(0x660F0F0F) + val colorIconBrand = Color(0xFF0090F9) + val colorIconStaticLight = Color(0xFF000000) + val colorIconStaticDark = Color(0xFFFFFFFF) + val colorIconInverse = Color(0xFFFFFFFF) + val colorIconStatusSuccess = Color(0xFF2DA30D) + val colorIconStatusError = Color(0xFFF25508) + val colorIconStatusWarning = Color(0xFFE68A03) + val colorIconStatusInfo = Color(0xFF0090F9) + val colorIconAccentBlue = Color(0xFF0090F9) + val colorIconAccentViolet = Color(0xFFA967FD) + val colorIconAccentRed = Color(0xFFFE4142) + val colorIconAccentOrange = Color(0xFFF25508) + val colorIconAccentYellow = Color(0xFFE68A03) + val colorIconAccentGreen = Color(0xFF2DA30D) + + // color.border + val colorBorderPrimary = Color(0x0D000000) + val colorBorderSecondary = Color(0x1A000000) + val colorBorderTertiary = Color(0x33000000) + val colorBorderBrand = Color(0xFF0090F9) + val colorBorderInversePrimary = Color(0x0D000000) + val colorBorderInverseSecondary = Color(0x1A000000) + val colorBorderInverseTertiary = Color(0x33000000) + val colorBorderStatusSuccess = Color(0xFF2DA30D) + val colorBorderStatusError = Color(0xFFF25508) + val colorBorderStatusWarning = Color(0xFFE68A03) + val colorBorderStatusInfo = Color(0xFF0090F9) + val colorBorderAccentBlue = Color(0xFF0090F9) + val colorBorderAccentViolet = Color(0xFFA967FD) + val colorBorderAccentRed = Color(0xFFFE4142) + val colorBorderAccentOrange = Color(0xFFF25508) + val colorBorderAccentYellow = Color(0xFFE68A03) + val colorBorderAccentGreen = Color(0xFF2DA30D) + + // color.overlay + val colorOverlayModal = Color(0x99000000) + + // color.interaction + val colorInteractionPress = Color(0x1A000000) + val colorInteractionPressStaticLight = Color(0x1A000000) + val colorInteractionPressStaticDark = Color(0x1AFFFFFF) + val colorInteractionPressInverse = Color(0x1AFFFFFF) + + // color.material + val colorMaterialTintGlass = Color(0x00000000) + val colorMaterialTintBlur = Color(0x00000000) + val colorMaterialTintSolid = Color(0x1AFFFFFF) + val colorMaterialFillGlass = Color(0x00000000) + val colorMaterialFillBlur = Color(0x99FFFFFF) + val colorMaterialFillSolid = Color(0xE6FFFFFF) + val colorMaterialLightenGlass = Color(0x80F7F7F7) + val colorMaterialLightenBlur = Color(0x00000000) + val colorMaterialLightenSolid = Color(0x00000000) + val colorMaterialSoftLightGlass = Color(0x33000000) + val colorMaterialSoftLightBlur = Color(0x00000000) + val colorMaterialSoftLightSolid = Color(0x00000000) + val colorMaterialBorderStart = Color(0x26000000) + val colorMaterialBorderMid = Color(0x00000000) + val colorMaterialBorderEnd = Color(0x1A000000) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemOpacityTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemOpacityTokens.kt new file mode 100644 index 0000000000..0de7875fdb --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemOpacityTokens.kt @@ -0,0 +1,22 @@ +package com.tangem.core.ui.res.generated + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +internal class TangemOpacityTokens { + val opacity0 = 0f + val opacity5 = 0.05f + val opacity10 = 0.1f + val opacity15 = 0.15f + val opacity20 = 0.2f + val opacity25 = 0.25f + val opacity30 = 0.3f + val opacity40 = 0.4f + val opacity50 = 0.5f + val opacity60 = 0.6f + val opacity70 = 0.7f + val opacity80 = 0.8f + val opacity90 = 0.9f + val opacity100 = 1f + val opacityDisabled = 0.4f +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemShadowTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemShadowTokens.kt new file mode 100644 index 0000000000..5677a413a4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemShadowTokens.kt @@ -0,0 +1,15 @@ +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +internal class TangemShadowTokens { + val shadowButtonBlur = 40.dp + val shadowButtonOffsetX = 0.dp + val shadowButtonOffsetY = 8.dp + val shadowButtonSpread = 0.dp + val shadowButtonColor = Color(0x1A000000) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypographyTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypographyTokens.kt new file mode 100644 index 0000000000..d3fcd82486 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypographyTokens.kt @@ -0,0 +1,88 @@ +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.sp +import com.tangem.core.ui.res.InterFamily + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +internal class TangemTypographyTokens { + val fontDisplayMedium = TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 44.sp, + lineHeight = 52.sp, + letterSpacing = (-0.92).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + lineBreak = LineBreak.Heading, + ) + + val fontHeadingMedium = TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 33.sp, + letterSpacing = (-0.37).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + lineBreak = LineBreak.Heading, + ) + + val fontHeadingSmall = TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 24.sp, + letterSpacing = (-0.12).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + lineBreak = LineBreak.Heading, + ) + + val fontBodyMedium = TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 20.sp, + letterSpacing = 0.02.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val fontSubheadingMedium = TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 17.sp, + letterSpacing = 0.07.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + + val fontCaptionMedium = TextStyle( + fontFamily = InterFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.18.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) +} \ No newline at end of file diff --git a/core/ui/token-gen/.gitignore b/core/ui/token-gen/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/core/ui/token-gen/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/core/ui/token-gen/build-tokens.mjs b/core/ui/token-gen/build-tokens.mjs new file mode 100644 index 0000000000..0add5d002f --- /dev/null +++ b/core/ui/token-gen/build-tokens.mjs @@ -0,0 +1,532 @@ +import StyleDictionary from 'style-dictionary'; +import { register, getTransforms } from '@tokens-studio/sd-transforms'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// ── Paths ────────────────────────────────────────────────────────────────────── +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const tokensDir = path.join(__dirname, '..', 'ds-tokens', 'tokens'); +const outputDir = path.join( + __dirname, '..', 'src', 'main', 'java', 'com', 'tangem', 'core', 'ui', 'res', 'generated', +); + +const PACKAGE = 'com.tangem.core.ui.res.generated'; + +fs.mkdirSync(outputDir, { recursive: true }); + +// ── Register sd-transforms ───────────────────────────────────────────────────── +// Use CSS platform for sd-transforms to get ts/color/css/hexrgba (resolves rgba to hex). +// Compose color conversion is done in the format function instead of as a transform, +// because the color/composeColor transform interferes with rgba reference resolution. +await register(StyleDictionary, { platform: 'css' }); + +// ── Token set definitions ────────────────────────────────────────────────────── +// Reference-only sets (provide values for other tokens but not in the output) +const coreSets = [ + 'core/palette', + 'core/font', + 'core/dimension', +]; + +// Theme-independent semantic sets +const sizeSets = [ + 'semantic/size/opacity', + 'semantic/size/blur', + 'semantic/size/border', + 'semantic/size/size', + 'semantic/size/spacing', +]; + +const fontSets = [ + 'semantic/font/sizes/android', + 'semantic/font/styles', +]; + +const sharedSets = [ + 'semantic/theme/shadows', + 'semantic/theme/gradient', +]; + +// Theme-specific sets +// Note: material variant files (glass/blur/solid) define colliding paths and are excluded. +// The material color tokens come from materials/light and materials/dark instead. +const themeBuilds = { + Light: { + sets: [ + ...coreSets, ...sizeSets, ...fontSets, ...sharedSets, + 'semantic/theme/light', + 'semantic/theme/materials/light', + ], + }, + Dark: { + sets: [ + ...coreSets, ...sizeSets, ...fontSets, ...sharedSets, + 'semantic/theme/dark', + 'semantic/theme/materials/dark', + ], + }, +}; + +// For theme-independent builds (dimensions, typography, etc.) +const sharedBuildSets = [...coreSets, ...sizeSets, ...fontSets, ...sharedSets]; + +for (const [theme, { sets }] of Object.entries(themeBuilds)) { + console.log(`${theme} theme: ${sets.length} token sets`); +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Convert token path segments to a camelCase property name */ +function toCamelCase(segments) { + return segments + .map((seg, i) => { + // kebab-case → camelCase + const cleaned = seg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); + if (i === 0) return cleaned; + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); + }) + .join(''); +} + +/** Check if a token is from core/palette, dimension, or gradient source sets (not for output) */ +function isSourceOnlyToken(token) { + const top = token.path[0]; + return top === 'palette' || top === 'dimension' || top === 'gradient'; +} + +/** + * Convert a resolved CSS color string to Compose Color(0xAARRGGBB). + * Handles: #RRGGBB, #AARRGGBB, rgba(r, g, b, a), rgb(r, g, b) + */ +function toComposeColor(value, tokenPath = '') { + if (typeof value !== 'string') { + throw new Error(`Unrecognized color value for ${tokenPath}: ${JSON.stringify(value)}`); + } + + // #RRGGBB + const hex6 = value.match(/^#([0-9a-fA-F]{6})$/); + if (hex6) return `Color(0xFF${hex6[1].toUpperCase()})`; + + // #AARRGGBB (8-digit hex) + const hex8 = value.match(/^#([0-9a-fA-F]{8})$/); + if (hex8) return `Color(0x${hex8[1].toUpperCase()})`; + + // rgba(r, g, b, a) + const rgba = value.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/); + if (rgba) { + const r = parseInt(rgba[1]).toString(16).padStart(2, '0').toUpperCase(); + const g = parseInt(rgba[2]).toString(16).padStart(2, '0').toUpperCase(); + const b = parseInt(rgba[3]).toString(16).padStart(2, '0').toUpperCase(); + const a = Math.round(parseFloat(rgba[4]) * 255).toString(16).padStart(2, '0').toUpperCase(); + return `Color(0x${a}${r}${g}${b})`; + } + + // rgb(r, g, b) + const rgb = value.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); + if (rgb) { + const r = parseInt(rgb[1]).toString(16).padStart(2, '0').toUpperCase(); + const g = parseInt(rgb[2]).toString(16).padStart(2, '0').toUpperCase(); + const b = parseInt(rgb[3]).toString(16).padStart(2, '0').toUpperCase(); + return `Color(0xFF${r}${g}${b})`; + } + + // Transparent + if (value === 'transparent' || value === '#00000000') return 'Color(0x00000000)'; + + throw new Error(`Unrecognized color format for ${tokenPath}: "${value}"`); +} + +/** Group tokens by their first N path segments */ +function groupByPath(tokens, depth = 1) { + const groups = {}; + for (const token of tokens) { + const key = token.path.slice(0, depth).join('.'); + if (!groups[key]) groups[key] = []; + groups[key].push(token); + } + return groups; +} + +// ── Custom formats ───────────────────────────────────────────────────────────── + +/** + * Kotlin format for color tokens. + * Generates: class TangemLightColorTokens / TangemDarkColorTokens + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-colors', + format: ({ dictionary, options }) => { + const themeName = options.themeName; // "Light" or "Dark" + const objectName = `Tangem${themeName}ColorTokens`; + + const colorTokens = dictionary.allTokens.filter( + t => t.$type === 'color' && !isSourceOnlyToken(t), + ); + + // Group by top-level category (color.text, color.bg, color.icon, etc.) + const groups = groupByPath(colorTokens, 2); + const sections = []; + + for (const [groupKey, tokens] of Object.entries(groups)) { + const comment = ` // ${groupKey}`; + const props = tokens.map(token => { + const propName = toCamelCase(token.path); + const value = toComposeColor(token.$value, token.path.join('.')); + return ` val ${propName} = ${value}`; + }); + sections.push([comment, ...props].join('\n')); + } + + return [ + `package ${PACKAGE}`, + '', + 'import androidx.compose.ui.graphics.Color', + '', + '/**', + ` * Auto-generated from design tokens. Do not edit manually.`, + ` * Theme: ${themeName}`, + ' */', + `internal class ${objectName} {`, + sections.join('\n\n'), + '}', + '', + ].join('\n'); + }, +}); + +/** + * Kotlin format for dimension tokens (spacing, size, border-radius, border-width). + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-dimensions', + format: ({ dictionary }) => { + const dimTokens = dictionary.allTokens.filter(t => { + // Exclude font tokens — letter-spacing values are already in typography tokens as sp + if (t.path[0] === 'font') return false; + return ( + (t.$type === 'dimension' || t.$type === 'borderRadius' || t.$type === 'borderWidth') && + !isSourceOnlyToken(t) + ); + }); + + const groups = groupByPath(dimTokens, 1); + const sections = []; + + for (const [groupKey, tokens] of Object.entries(groups)) { + const comment = ` // ${groupKey}`; + const props = tokens.map(token => { + const propName = toCamelCase(token.path); + // Resolved value is in px (number), convert to dp + const raw = parseFloat(token.$value); + if (isNaN(raw)) throw new Error(`Non-numeric dimension value for ${token.path.join('.')}: "${token.$value}"`); + const dpVal = `${raw}.dp`; + return ` val ${propName} = ${dpVal}`; + }); + sections.push([comment, ...props].join('\n')); + } + + return [ + `package ${PACKAGE}`, + '', + 'import androidx.compose.ui.unit.dp', + '', + '/**', + ' * Auto-generated from design tokens. Do not edit manually.', + ' */', + 'internal class TangemDimensionTokens {', + sections.join('\n\n'), + '}', + '', + ].join('\n'); + }, +}); + +/** + * Kotlin format for opacity tokens. + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-opacity', + format: ({ dictionary }) => { + const opacityTokens = dictionary.allTokens.filter( + t => t.$type === 'opacity' && !isSourceOnlyToken(t), + ); + + const props = opacityTokens.map(token => { + const propName = toCamelCase(token.path); + const raw = parseFloat(token.$value); + if (isNaN(raw)) throw new Error(`Non-numeric opacity value for ${token.path.join('.')}: "${token.$value}"`); + const floatVal = `${raw}f`; + return ` val ${propName} = ${floatVal}`; + }); + + return [ + `package ${PACKAGE}`, + '', + '/**', + ' * Auto-generated from design tokens. Do not edit manually.', + ' */', + 'internal class TangemOpacityTokens {', + ...props, + '}', + '', + ].join('\n'); + }, +}); + +/** + * Kotlin format for typography tokens. + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-typography', + format: ({ dictionary }) => { + const typoTokens = dictionary.allTokens.filter( + t => t.$type === 'typography' && !isSourceOnlyToken(t), + ); + + const props = typoTokens.map(token => { + const propName = toCamelCase(token.path); + const v = token.$value; + + // v is an object: { fontFamily, fontWeight, fontSize, lineHeight, letterSpacing, ... } + const tp = token.path.join('.'); + if (!v.fontWeight) throw new Error(`Missing fontWeight for ${tp}`); + const fontWeight = mapFontWeight(v.fontWeight); + const fontSize = parseFloat(v.fontSize); + if (isNaN(fontSize)) throw new Error(`Non-numeric fontSize for ${tp}: "${v.fontSize}"`); + const lineHeight = parseFloat(v.lineHeight); + if (isNaN(lineHeight)) throw new Error(`Non-numeric lineHeight for ${tp}: "${v.lineHeight}"`); + const letterSpacing = parseFloat(v.letterSpacing); + if (isNaN(letterSpacing)) throw new Error(`Non-numeric letterSpacing for ${tp}: "${v.letterSpacing}"`); + + // display and heading categories get LineBreak.Heading (matches TangemTypography2) + const category = token.path[1]; // display, heading, body, subheading, caption + const isHeading = category === 'display' || category === 'heading'; + + const lines = [ + ` val ${propName} = TextStyle(`, + ` fontFamily = InterFamily,`, + ` fontWeight = ${fontWeight},`, + ` fontSize = ${fontSize}.sp,`, + ` lineHeight = ${lineHeight}.sp,`, + ` letterSpacing = ${letterSpacing < 0 ? `(${letterSpacing})` : letterSpacing}.sp,`, + ` lineHeightStyle = LineHeightStyle(`, + ` alignment = LineHeightStyle.Alignment.Center,`, + ` trim = LineHeightStyle.Trim.None,`, + ` ),`, + ]; + if (isHeading) { + lines.push(` lineBreak = LineBreak.Heading,`); + } + lines.push(` )`); + + return lines.join('\n'); + }); + + return [ + `package ${PACKAGE}`, + '', + 'import androidx.compose.ui.text.TextStyle', + 'import androidx.compose.ui.text.font.FontWeight', + 'import androidx.compose.ui.text.style.LineBreak', + 'import androidx.compose.ui.text.style.LineHeightStyle', + 'import androidx.compose.ui.unit.sp', + 'import com.tangem.core.ui.res.InterFamily', + '', + '/**', + ' * Auto-generated from design tokens. Do not edit manually.', + ' */', + 'internal class TangemTypographyTokens {', + props.join('\n\n'), + '}', + '', + ].join('\n'); + }, +}); + +function mapFontWeight(value) { + const num = parseInt(value, 10); + if (!isNaN(num)) { + if (num <= 400) return 'FontWeight.Normal'; + if (num <= 500) return 'FontWeight.Medium'; + if (num <= 600) return 'FontWeight.SemiBold'; + return 'FontWeight.Bold'; + } + const lower = String(value).toLowerCase(); + if (lower.includes('semibold') || lower.includes('semi bold')) return 'FontWeight.SemiBold'; + if (lower.includes('bold')) return 'FontWeight.Bold'; + if (lower.includes('medium')) return 'FontWeight.Medium'; + return 'FontWeight.Normal'; +} + +/** + * Kotlin format for shadow tokens. + * Shadow tokens are composite (type: shadow) with object $value containing + * blur, spread, color, offsetX, offsetY, type. + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-shadows', + format: ({ dictionary }) => { + const shadowTokens = dictionary.allTokens.filter( + t => (t.$type === 'shadow' || t.$type === 'boxShadow') && !isSourceOnlyToken(t), + ); + + const entries = shadowTokens.map(token => { + const propName = toCamelCase(token.path); + const v = token.$value; + const sp = token.path.join('.'); + if (!v) throw new Error(`Missing shadow value for ${sp}`); + const blur = parseFloat(v.blur); + if (isNaN(blur)) throw new Error(`Non-numeric blur for ${sp}: "${v.blur}"`); + const spread = parseFloat(v.spread); + if (isNaN(spread)) throw new Error(`Non-numeric spread for ${sp}: "${v.spread}"`); + const offsetX = parseFloat(v.offsetX); + if (isNaN(offsetX)) throw new Error(`Non-numeric offsetX for ${sp}: "${v.offsetX}"`); + const offsetY = parseFloat(v.offsetY); + if (isNaN(offsetY)) throw new Error(`Non-numeric offsetY for ${sp}: "${v.offsetY}"`); + if (!v.color) throw new Error(`Missing color for ${sp}`); + const colorVal = toComposeColor(v.color, sp + '.color'); + + return [ + ` val ${propName}Blur = ${blur}.dp`, + ` val ${propName}OffsetX = ${offsetX}.dp`, + ` val ${propName}OffsetY = ${offsetY}.dp`, + ` val ${propName}Spread = ${spread}.dp`, + ` val ${propName}Color = ${colorVal}`, + ].join('\n'); + }); + + return [ + `package ${PACKAGE}`, + '', + 'import androidx.compose.ui.graphics.Color', + 'import androidx.compose.ui.unit.dp', + '', + '/**', + ' * Auto-generated from design tokens. Do not edit manually.', + ' */', + 'internal class TangemShadowTokens {', + entries.join('\n\n'), + '}', + '', + ].join('\n'); + }, +}); + +// ── Build ────────────────────────────────────────────────────────────────────── + +const composePlatformTransforms = [ + ...getTransforms({ platform: 'css' }), + 'name/camel', +]; + +// Build color tokens per theme (light/dark) +for (const [themeName, { sets }] of Object.entries(themeBuilds)) { + console.log(`\nBuilding ${themeName} color tokens...`); + + const sd = new StyleDictionary({ + source: sets.map(s => path.join(tokensDir, `${s}.json`)), + preprocessors: ['tokens-studio'], + usesDtcg: true, + log: { warnings: 'disabled', errors: { brokenReferences: 'console' } }, + platforms: { + compose: { + transforms: composePlatformTransforms, + buildPath: outputDir + '/', + files: [ + { + destination: `Tangem${themeName}ColorTokens.kt`, + format: 'kotlin/compose-colors', + options: { themeName }, + filter: token => token.$type === 'color' && !isSourceOnlyToken(token), + }, + ], + }, + }, + }); + + await sd.buildAllPlatforms(); + console.log(` ✓ Tangem${themeName}ColorTokens.kt`); +} + +// Build theme-independent tokens (dimensions, opacity, typography, shadows) +console.log('\nBuilding dimension, opacity, typography, and shadow tokens...'); + +const sd = new StyleDictionary({ + source: sharedBuildSets.map(s => path.join(tokensDir, `${s}.json`)), + preprocessors: ['tokens-studio'], + usesDtcg: true, + log: { warnings: 'disabled', errors: { brokenReferences: 'console' } }, + platforms: { + compose: { + transforms: composePlatformTransforms, + buildPath: outputDir + '/', + files: [ + { + destination: 'TangemDimensionTokens.kt', + format: 'kotlin/compose-dimensions', + filter: token => { + const t = token.$type; + return ( + token.path[0] !== 'font' && + (t === 'dimension' || t === 'borderRadius' || t === 'borderWidth') && + !isSourceOnlyToken(token) + ); + }, + }, + { + destination: 'TangemOpacityTokens.kt', + format: 'kotlin/compose-opacity', + filter: token => token.$type === 'opacity' && !isSourceOnlyToken(token), + }, + { + destination: 'TangemTypographyTokens.kt', + format: 'kotlin/compose-typography', + filter: token => token.$type === 'typography' && !isSourceOnlyToken(token), + }, + { + destination: 'TangemShadowTokens.kt', + format: 'kotlin/compose-shadows', + filter: token => (token.$type === 'shadow' || token.$type === 'boxShadow') && !isSourceOnlyToken(token), + }, + ], + }, + }, +}); + +await sd.buildAllPlatforms(); +console.log(' ✓ TangemDimensionTokens.kt'); +console.log(' ✓ TangemOpacityTokens.kt'); +console.log(' ✓ TangemTypographyTokens.kt'); +console.log(' ✓ TangemShadowTokens.kt'); + +// ── Write source hash ───────────────────────────────────────────────────────── +// Hash all token JSON files so Gradle can verify generated code matches ds-tokens. +function computeTokensHash() { + const files = []; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.json')) files.push(full); + } + } + walk(tokensDir); + files.sort(); // deterministic order + + const hash = crypto.createHash('sha256'); + for (const file of files) { + hash.update(path.relative(tokensDir, file).split(path.sep).join('/')); + hash.update('\0'); + hash.update(fs.readFileSync(file)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +const tokensHash = computeTokensHash(); +fs.writeFileSync(path.join(outputDir, '.tokens-hash'), tokensHash + '\n'); +console.log(` ✓ .tokens-hash (${tokensHash.substring(0, 12)}…)`); + +console.log(`\nDone! Output: ${outputDir}`); diff --git a/core/ui/token-gen/package-lock.json b/core/ui/token-gen/package-lock.json new file mode 100644 index 0000000000..2506a02e51 --- /dev/null +++ b/core/ui/token-gen/package-lock.json @@ -0,0 +1,1749 @@ +{ + "name": "tangem-token-gen", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tangem-token-gen", + "version": "1.0.0", + "devDependencies": { + "@tokens-studio/sd-transforms": "^2.0.3", + "style-dictionary": "^5.4.0" + } + }, + "node_modules/@bundled-es-modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-Rk453EklPUPC3NRWc3VUNI/SSUjdBaFoaQvFRmNBNtMHVtOFD5AntiWg5kEE1hqcPqedYFDzxE3ZcMYPcA195w==", + "dev": true, + "license": "ISC", + "dependencies": { + "deepmerge": "^4.3.1" + } + }, + "node_modules/@bundled-es-modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-x9nR2e1pt8LF0yLPC6yz/aUoiN7qJJwZ1znLxIXCxGyH+8BI+yO/sklBdn1+QbUyWXQBM+CjfZz3IhqtgIoDVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "events": "^3.3.0", + "glob": "^13.0.6", + "path": "^0.12.7", + "stream": "^0.0.3", + "string_decoder": "^1.3.0", + "url": "^0.11.4" + } + }, + "node_modules/@bundled-es-modules/memfs": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/memfs/-/memfs-4.17.0.tgz", + "integrity": "sha512-ykdrkEmQr9BV804yd37ikXfNnvxrwYfY9Z2/EtMHFEFadEjsQXJ1zL9bVZrKNLDtm91UdUOEHso6Aweg93K6xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "assert": "^2.1.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "memfs": "^4.17.0", + "path": "^0.12.7", + "stream": "^0.0.3", + "util": "^0.12.5" + } + }, + "node_modules/@bundled-es-modules/postcss-calc-ast-parser": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.6.tgz", + "integrity": "sha512-y65TM5zF+uaxo9OeekJ3rxwTINlQvrkbZLogYvQYVoLtxm4xEiHfZ7e/MyiWbStYyWZVZkVqsaVU6F4SUK5XUA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-calc-ast-parser": "^0.1.4" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.1.tgz", + "integrity": "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.1.tgz", + "integrity": "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.1.tgz", + "integrity": "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-print": "4.57.1", + "@jsonjoy.com/fs-snapshot": "4.57.1", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.1.tgz", + "integrity": "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.1.tgz", + "integrity": "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.1.tgz", + "integrity": "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.57.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.1.tgz", + "integrity": "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.57.1", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.1.tgz", + "integrity": "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@tokens-studio/sd-transforms": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@tokens-studio/sd-transforms/-/sd-transforms-2.0.3.tgz", + "integrity": "sha512-PyrmRb7FuJBHzsbuWNk/O06hJbaZ+RL7chmK7PRbEptaSruhANai7Kxja5CiYnTcxOj373uRMDPxoFwCHaVpvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/deepmerge": "^4.3.1", + "@bundled-es-modules/postcss-calc-ast-parser": "^0.1.6", + "@tokens-studio/types": "^0.5.1", + "colorjs.io": "^0.5.2", + "expr-eval-fork": "^3.0.1", + "is-mergeable-object": "^1.1.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "style-dictionary": "^5.0.0" + } + }, + "node_modules/@tokens-studio/types": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@tokens-studio/types/-/types-0.5.2.tgz", + "integrity": "sha512-rzMcZP0bj2E5jaa7Fj0LGgYHysoCrbrxILVbT0ohsCUH5uCHY/u6J7Qw/TE0n6gR9Js/c9ZO9T8mOoz0HdLMbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorjs.io": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", + "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/component-emitter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz", + "integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expr-eval-fork": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.3.tgz", + "integrity": "sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-mergeable-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-mergeable-object/-/is-mergeable-object-1.1.1.tgz", + "integrity": "sha512-CPduJfuGg8h8vW74WOxHtHmtQutyQBzR+3MjQ6iDHIYdbOnm1YC7jv43SqCoU8OPGTJD4nibmiryA4kmogbGrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memfs": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.1.tgz", + "integrity": "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.57.1", + "@jsonjoy.com/fs-fsa": "4.57.1", + "@jsonjoy.com/fs-node": "4.57.1", + "@jsonjoy.com/fs-node-builtins": "4.57.1", + "@jsonjoy.com/fs-node-to-fsa": "4.57.1", + "@jsonjoy.com/fs-node-utils": "4.57.1", + "@jsonjoy.com/fs-print": "4.57.1", + "@jsonjoy.com/fs-snapshot": "4.57.1", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-unified": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/path-unified/-/path-unified-0.2.0.tgz", + "integrity": "sha512-MNKqvrKbbbb5p7XHXV6ZAsf/1f/yJQa13S/fcX0uua8ew58Tgc6jXV+16JyAbnR/clgCH+euKDxrF2STxMHdrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/path/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/path/node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss-calc-ast-parser": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/postcss-calc-ast-parser/-/postcss-calc-ast-parser-0.1.4.tgz", + "integrity": "sha512-CebpbHc96zgFjGgdQ6BqBy6XIUgRx1xXWCAAk6oke02RZ5nxwo9KQejTg8y7uYEeI9kv8jKQPYjoe6REsY23vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^3.3.1" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/postcss-value-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", + "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stream": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.3.tgz", + "integrity": "sha512-aMsbn7VKrl4A2T7QAQQbzgN7NVc70vgF5INQrBXqn4dCXN1zy3L9HGgLO5s7PExmdrzTJ8uR/27aviW8or8/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^2.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/style-dictionary": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/style-dictionary/-/style-dictionary-5.4.0.tgz", + "integrity": "sha512-6BzO0DV19t6KUEXYfvHJ73d3y8bBDcd0wNLfoZRX817obJ8YX5Vev8Xh3+k9601tHE8qRJ/586iLt0byuY2THw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bundled-es-modules/deepmerge": "^4.3.1", + "@bundled-es-modules/glob": "^13.0.6", + "@bundled-es-modules/memfs": "^4.17.0", + "@zip.js/zip.js": "^2.7.44", + "chalk": "^5.3.0", + "change-case": "^5.3.0", + "colorjs.io": "^0.5.2", + "commander": "^12.1.0", + "is-plain-obj": "^4.1.0", + "json5": "^2.2.2", + "path-unified": "^0.2.0", + "prettier": "^3.3.3", + "tinycolor2": "^1.6.0" + }, + "bin": { + "style-dictionary": "bin/style-dictionary.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/thingies": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + } + } +} diff --git a/core/ui/token-gen/package.json b/core/ui/token-gen/package.json new file mode 100644 index 0000000000..cfb40a9fc8 --- /dev/null +++ b/core/ui/token-gen/package.json @@ -0,0 +1,13 @@ +{ + "name": "tangem-token-gen", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "node build-tokens.mjs" + }, + "devDependencies": { + "style-dictionary": "^5.4.0", + "@tokens-studio/sd-transforms": "^2.0.3" + } +} From f8268dbe6e4edd69ca51d4783d3a330e92ef5aaf Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 20 Apr 2026 10:53:25 +0400 Subject: [PATCH 086/206] Updated on 2026-08-14 --- .../kotlin/com/tangem/tests/FeedbackTest.kt | 9 ++-- .../com/tangem/tap/LockUserWalletsTimer.kt | 7 ++-- .../main/java/com/tangem/tap/MainActivity.kt | 17 +++----- .../java/com/tangem/tap/TangemApplication.kt | 7 ++-- .../CardContextInterceptor.kt | 6 +-- .../tangem/tap/di/TangemSdkManagerModule.kt | 3 ++ .../tap/di/domain/CardLegacyDomainModule.kt | 13 +++++- .../scanCard/DefaultScanCardProcessor.kt | 11 +++-- .../domain/scanCard/LegacyScanProcessor.kt | 29 +++++++------ .../domain/scanCard/UseCaseScanProcessor.kt | 41 +++++++++++-------- .../chains/CheckForOnboardingChain.kt | 19 ++++----- .../domain/scanCard/chains/DisclaimerChain.kt | 17 +++----- .../scanCard/chains/ScanChainException.kt | 9 ---- .../utils/ScanCardExceptionConverter.kt | 1 - .../sdk/impl/DefaultTangemSdkManager.kt | 4 ++ .../domain/tasks/product/ScanProductTask.kt | 23 +++++++---- .../tap/domain/twins/FinalizeTwinTask.kt | 3 ++ .../tap/features/disclaimer/DisclaimerType.kt | 11 ++--- .../tangem/tap/features/main/MainViewModel.kt | 4 -- .../features/onboarding/OnboardingHelper.kt | 17 ++++---- 20 files changed, 126 insertions(+), 125 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index 7db589cb4a..528a6535fb 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -8,6 +8,7 @@ import com.tangem.common.core.TangemSdkError import com.tangem.common.extensions.clickAndWaitFor import com.tangem.common.extensions.clickWithAssertion import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.domain.card.ScanFailsRequester import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import com.tangem.scenarios.checkFailedTransactionDialog @@ -28,16 +29,19 @@ import com.tangem.screens.onStoriesScreen import com.tangem.screens.onTokenDetailsScreen import com.tangem.screens.onMainScreenTopBar import com.tangem.tap.domain.sdk.mocks.MockProvider -import com.tangem.tap.store import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName import org.junit.Ignore import org.junit.Test +import javax.inject.Inject @HiltAndroidTest class FeedbackTest : BaseTestCase() { + @Inject + lateinit var scanFailsRequester: ScanFailsRequester + @AllureId("894") @DisplayName("Send feedback: from details") @Test @@ -177,9 +181,8 @@ class FeedbackTest : BaseTestCase() { } step("Force show 'Scan warning' dialog"){ runOnUiThread { - val requester = store.state.daggerGraphState.scanFailsRequester!! MainScope().launch { - requester.show(AnalyticsParam.ScreensSources.Main) + scanFailsRequester.show(AnalyticsParam.ScreensSources.Main) } } } diff --git a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt index 0798bcf8cf..63dcc8eeee 100644 --- a/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt +++ b/app/src/main/java/com/tangem/tap/LockUserWalletsTimer.kt @@ -7,12 +7,12 @@ import androidx.lifecycle.LifecycleOwner import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.usecase.ClearAllHotWalletContextualUnlockUseCase import com.tangem.tap.LockTimerWorker.Companion.TAG -import com.tangem.tap.common.extensions.dispatchNavigationAction import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -30,6 +30,7 @@ internal class LockUserWalletsTimer( private val coroutineScope: CoroutineScope, private val clearAllHotWalletContextualUnlockUseCase: ClearAllHotWalletContextualUnlockUseCase, private val passwordRequester: HotWalletPasswordRequester, + private val appRouter: AppRouter, ) : LifecycleOwner by context as LifecycleOwner, DefaultLifecycleObserver { @@ -57,7 +58,7 @@ internal class LockUserWalletsTimer( if (shouldOpenWelcomeScreenOnResume) { passwordRequester.dismiss() clearAllHotWalletContextualUnlockUseCase.invoke() - store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) } + appRouter.replaceAll(AppRoute.Welcome()) settingsRepository.setShouldOpenWelcomeScreenOnResume(value = false) } } @@ -121,7 +122,7 @@ internal class LockUserWalletsTimer( .onRight { passwordRequester.dismiss() clearAllHotWalletContextualUnlockUseCase.invoke() - store.dispatchNavigationAction { replaceAll(AppRoute.Welcome()) } + appRouter.replaceAll(AppRoute.Welcome()) } } } diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index 364671d88f..7172772521 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -28,6 +28,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import arrow.core.getOrElse +import com.tangem.common.routing.AppRouter import com.tangem.common.routing.deeplink.DeeplinkConst.WEBLINK_KEY import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -39,7 +40,6 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.data.balancehiding.DefaultDeviceFlipDetector import com.tangem.data.card.sdk.CardSdkOwner import com.tangem.domain.apptheme.model.AppThemeMode -import com.tangem.domain.card.ScanCardUseCase import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -61,7 +61,6 @@ import com.tangem.tap.common.analytics.events.Push import com.tangem.tap.common.apptheme.MutableAppThemeModeHolder import com.tangem.tap.features.intentHandler.handlers.BackgroundScanIntentHandler import com.tangem.tap.features.main.MainViewModel -import com.tangem.tap.proxy.redux.DaggerGraphAction import com.tangem.tap.routing.component.RoutingComponent import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.tap.routing.utils.DeepLinkFactory @@ -104,9 +103,6 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject lateinit var injectedTangemSdkManager: TangemSdkManager - @Inject - lateinit var scanCardUseCase: ScanCardUseCase - @Inject lateinit var settingsRepository: SettingsRepository @@ -123,6 +119,9 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { @Inject internal lateinit var appRouterConfig: AppRouterConfig + @Inject + internal lateinit var appRouter: AppRouter + @Inject internal lateinit var routingComponentFactory: RoutingComponent.Factory @@ -269,13 +268,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { userWalletsListRepository = userWalletsListRepository, clearAllHotWalletContextualUnlockUseCase = clearAllHotWalletContextualUnlockUseCase, passwordRequester = passwordRequester, - ) - - store.dispatch( - DaggerGraphAction.SetActivityDependencies( - scanCardUseCase = scanCardUseCase, - cardSdkConfigRepository = cardSdkConfigRepository, - ), + appRouter = appRouter, ) } diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 46f0aff6cb..ba43027039 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -79,6 +79,8 @@ import org.rekotlin.Store lateinit var store: Store +lateinit var walletsRepository: WalletsRepository + val foregroundActivityObserver = ForegroundActivityObserver open class TangemApplication : Application(), ImageLoaderFactory, Configuration.Provider { @@ -129,9 +131,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. val getAppThemeModeUseCase: GetAppThemeModeUseCase get() = entryPoint.getGetAppThemeModeUseCase() - private val walletsRepository: WalletsRepository - get() = entryPoint.getWalletsRepository() - private val oneTimeEventFilter: OneTimeEventFilter get() = entryPoint.getOneTimeEventFilter() @@ -276,6 +275,8 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. } fun init() { + walletsRepository = entryPoint.getWalletsRepository() + apiConfigsManager.initialize() store = createReduxStore() diff --git a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt index 5b5902f3cc..24e95b0b93 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt @@ -10,10 +10,8 @@ import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.wallets.builder.UserWalletIdBuilder -import com.tangem.tap.common.extensions.inject import com.tangem.tap.features.demo.DemoHelper -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store +import com.tangem.tap.walletsRepository import kotlinx.coroutines.runBlocking /** @@ -23,8 +21,6 @@ class CardContextInterceptor( private val scanResponse: ScanResponse, ) : ParamsInterceptor { - private val walletsRepository = store.inject(DaggerGraphState::walletsRepository) - private val userWalletId = UserWalletIdBuilder.scanResponse(scanResponse).build() override fun id(): String = CardContextInterceptor.id() diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index cca1b670ed..61da4b0ea5 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -3,6 +3,7 @@ package com.tangem.tap.di import android.content.Context import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.domain.card.BuildConfig +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles @@ -36,6 +37,7 @@ internal class TangemSdkManagerModule { dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, blockchainToDeriveFinder: BlockchainToDeriveFinder, analyticsErrorHandler: AnalyticsErrorHandler, + cardRepository: CardRepository, ): TangemSdkManager { return if (BuildConfig.MOCK_DATA_SOURCE) { MockTangemSdkManager(resources = context.resources) @@ -50,6 +52,7 @@ internal class TangemSdkManagerModule { dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, blockchainToDeriveFinder = blockchainToDeriveFinder, analyticsErrorHandler = analyticsErrorHandler, + cardRepository = cardRepository, ) } } diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardLegacyDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardLegacyDomainModule.kt index db3b9aa4e2..5a193f32f7 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardLegacyDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardLegacyDomainModule.kt @@ -7,6 +7,7 @@ import com.tangem.domain.wallets.usecase.GenerateWalletNameUseCase import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles import com.tangem.tap.domain.scanCard.DefaultScanCardProcessor import com.tangem.tap.domain.scanCard.LegacyScanProcessor +import com.tangem.tap.domain.scanCard.UseCaseScanProcessor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,8 +26,16 @@ internal object CardLegacyDomainModule { @Provides @Singleton - fun provideScanCardProcessor(legacyScanProcessor: LegacyScanProcessor): ScanCardProcessor { - return DefaultScanCardProcessor(legacyScanProcessor = legacyScanProcessor) + fun provideScanCardProcessor( + legacyScanProcessor: LegacyScanProcessor, + useCaseScanProcessor: UseCaseScanProcessor, + cardScanningFeatureToggles: CardScanningFeatureToggles, + ): ScanCardProcessor { + return DefaultScanCardProcessor( + legacyScanProcessor = legacyScanProcessor, + useCaseScanProcessor = useCaseScanProcessor, + cardScanningFeatureToggles = cardScanningFeatureToggles, + ) } @Provides diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt index f04b03cef6..c784137e08 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/DefaultScanCardProcessor.kt @@ -5,16 +5,15 @@ import com.tangem.common.core.TangemError import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store // TODO: Remove this object after feature toggle was removed and use ScanCardUseCase instead internal class DefaultScanCardProcessor( private val legacyScanProcessor: LegacyScanProcessor, + private val useCaseScanProcessor: UseCaseScanProcessor, + private val cardScanningFeatureToggles: CardScanningFeatureToggles, ) : ScanCardProcessor { private val isNewCardScanningEnabled: Boolean - get() = store.inject(DaggerGraphState::cardScanningFeatureToggles).isNewCardScanningEnabled + get() = cardScanningFeatureToggles.isNewCardScanningEnabled override suspend fun scan( cardId: String?, @@ -23,7 +22,7 @@ internal class DefaultScanCardProcessor( shouldCheckIsAlreadyActivated: Boolean, ): CompletionResult { return if (isNewCardScanningEnabled) { - UseCaseScanProcessor.scan(cardId, allowsRequestAccessCodeFromRepository) + useCaseScanProcessor.scan(cardId, allowsRequestAccessCodeFromRepository) } else { legacyScanProcessor.scan( analyticsSource = analyticsSource, @@ -47,7 +46,7 @@ internal class DefaultScanCardProcessor( onSuccess: suspend (scanResponse: ScanResponse) -> Unit, ) { if (isNewCardScanningEnabled) { - UseCaseScanProcessor.scan( + useCaseScanProcessor.scan( analyticsSource = analyticsSource, cardId = cardId, onProgressStateChange = onProgressStateChange, diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index 3c0b311ca7..10dafea284 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -6,6 +6,7 @@ import com.tangem.common.core.TangemSdkError import com.tangem.common.doOnFailure import com.tangem.common.doOnSuccess import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsEvent @@ -21,19 +22,18 @@ import com.tangem.core.ui.extensions.toWrappedList import com.tangem.core.ui.message.dialog.Dialogs import com.tangem.domain.card.ScanFailsCounter import com.tangem.domain.card.common.util.twinsIsTwinned +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.common.extensions.withMainContext +import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.sdk.extensions.localizedDescriptionRes import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.inject import com.tangem.tap.features.disclaimer.createDisclaimer import com.tangem.tap.features.onboarding.OnboardingHelper import com.tangem.tap.mainScope -import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope -import com.tangem.tap.store import com.tangem.tap.tangemSdkManager import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.Dispatchers @@ -44,11 +44,17 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton +@Suppress("LongParameterList") internal class LegacyScanProcessor @Inject constructor( @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, private val trackingContextProxy: TrackingContextProxy, private val scanFailsCounter: ScanFailsCounter, + private val appRouter: AppRouter, + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, + private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase, + private val cardRepository: CardRepository, + private val onboardingHelper: OnboardingHelper, ) { suspend fun scan( @@ -146,7 +152,7 @@ internal class LegacyScanProcessor @Inject constructor( crossinline disclaimerWillShow: () -> Unit = {}, crossinline nextHandler: suspend (ScanResponse) -> Unit, ) { - val disclaimer = scanResponse.card.createDisclaimer() + val disclaimer = scanResponse.card.createDisclaimer(cardRepository) if (disclaimer.isAccepted()) { nextHandler(scanResponse) @@ -157,9 +163,7 @@ internal class LegacyScanProcessor @Inject constructor( withContext(Dispatchers.Main.immediate) { disclaimerWillShow() - store.dispatchNavigationAction { - push(AppRoute.Disclaimer(isTosAccepted = false)) - } + appRouter.push(AppRoute.Disclaimer(isTosAccepted = false)) } } } @@ -189,7 +193,7 @@ internal class LegacyScanProcessor @Inject constructor( mainScope.launch { onCancel() - store.inject(DaggerGraphState::sendFeedbackEmailUseCase).invoke( + sendFeedbackEmailUseCase.invoke( type = FeedbackEmailType.CardAttestationFailed, ) } @@ -209,7 +213,7 @@ internal class LegacyScanProcessor @Inject constructor( crossinline onWalletNotCreated: suspend () -> Unit, crossinline onSuccess: suspend (ScanResponse) -> Unit, ) { - if (OnboardingHelper.isOnboardingCase(scanResponse)) { + if (onboardingHelper.isOnboardingCase(scanResponse)) { trackingContextProxy.addContext(scanResponse) onWalletNotCreated() navigateTo( @@ -221,8 +225,7 @@ internal class LegacyScanProcessor @Inject constructor( } else { trackingContextProxy.setContext(scanResponse) - val wasTwinsOnboardingShown = - store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase).invokeSync() + val wasTwinsOnboardingShown = wasTwinsOnboardingShownUseCase.invokeSync() if (scanResponse.twinsIsTwinned() && !wasTwinsOnboardingShown) { onWalletNotCreated() @@ -241,7 +244,7 @@ internal class LegacyScanProcessor @Inject constructor( private suspend inline fun navigateTo(route: AppRoute, onProgressStateChange: (showProgress: Boolean) -> Unit) { delay(DELAY_SDK_DIALOG_CLOSE) - store.dispatchNavigationAction { push(route) } + appRouter.push(route) onProgressStateChange(false) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt index d9bbaccdb4..38ee2a3f54 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/UseCaseScanProcessor.kt @@ -4,33 +4,46 @@ import arrow.fx.coroutines.resourceScope import com.tangem.common.CompletionResult import com.tangem.common.core.TangemError import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.ExceptionAnalyticsEvent +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.domain.card.ScanCardException +import com.tangem.domain.card.ScanCardUseCase +import com.tangem.domain.card.ScanFailsRequester +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.extensions.inject import com.tangem.tap.domain.scanCard.chains.* import com.tangem.tap.domain.scanCard.utils.ScanCardExceptionConverter -import com.tangem.tap.proxy.redux.DaggerGraphState +import com.tangem.tap.features.onboarding.OnboardingHelper import com.tangem.tap.scope -import com.tangem.tap.store import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton -internal object UseCaseScanProcessor { +@Singleton +@Suppress("LongParameterList") +internal class UseCaseScanProcessor @Inject constructor( + private val scanCardUseCase: ScanCardUseCase, + private val scanFailsRequester: ScanFailsRequester, + private val appRouter: AppRouter, + private val trackingContextProxy: TrackingContextProxy, + private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase, + private val cardRepository: CardRepository, + private val onboardingHelper: OnboardingHelper, +) { private val scanCardExceptionConverter = ScanCardExceptionConverter() suspend fun scan( cardId: String? = null, allowsRequestAccessCodeFromRepository: Boolean = false, ): CompletionResult { - val scanCardUseCase = store.inject(DaggerGraphState::scanCardUseCase) - return scanCardUseCase(cardId, allowsRequestAccessCodeFromRepository) .fold( ifLeft = { scanCardException -> @@ -53,7 +66,6 @@ internal object UseCaseScanProcessor { onFailure: suspend (error: TangemError) -> Unit, onSuccess: suspend (scanResponse: ScanResponse) -> Unit, ) = progressScope(onProgressStateChange) { - val scanCardUseCase = store.inject(DaggerGraphState::scanCardUseCase) val chains = buildList { add( FailedScansCounterChain( @@ -61,8 +73,8 @@ internal object UseCaseScanProcessor { ), ) add(AnalyticsChain(Basic.CardWasScanned(analyticsSource))) - add(DisclaimerChain(store, disclaimerWillShow)) - add(CheckForOnboardingChain(store)) + add(DisclaimerChain(appRouter, cardRepository, disclaimerWillShow)) + add(CheckForOnboardingChain(trackingContextProxy, wasTwinsOnboardingShownUseCase, onboardingHelper)) } scanCardUseCase(cardId, afterScanChains = chains).fold( @@ -73,7 +85,7 @@ internal object UseCaseScanProcessor { private fun showScanFailsDialog(source: AnalyticsParam.ScreensSources) { scope.launch { - store.inject(DaggerGraphState::scanFailsRequester).show(source) + scanFailsRequester.show(source) } } @@ -86,7 +98,6 @@ internal object UseCaseScanProcessor { is ScanCardException.ChainException -> proceedWithScanChainException( exception, onWalletNotCreated, - onFailure, ) is ScanCardException.UnknownException, is ScanCardException.UserCancelled, @@ -109,16 +120,12 @@ internal object UseCaseScanProcessor { private suspend fun proceedWithScanChainException( exception: ScanCardException.ChainException, onWalletNotCreated: suspend () -> Unit, - onFailure: suspend (error: TangemError) -> Unit, ) { when (exception) { is ScanChainException.OnboardingNeeded -> { navigateTo(exception.onboardingRoute) onWalletNotCreated() } - is ScanChainException.DisclaimerWasCanceled -> { - onFailure(scanCardExceptionConverter.convertBack(exception)) - } } } @@ -136,6 +143,6 @@ internal object UseCaseScanProcessor { private suspend inline fun navigateTo(route: AppRoute) { delay(DELAY_SDK_DIALOG_CLOSE) - store.dispatchNavigationAction { push(route) } + appRouter.push(route) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt index a3693ba6d2..6576186613 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/CheckForOnboardingChain.kt @@ -3,18 +3,16 @@ package com.tangem.tap.domain.scanCard.chains import arrow.core.left import arrow.core.right import com.tangem.common.routing.AppRoute +import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.domain.card.ScanCardException import com.tangem.domain.card.common.util.twinsIsTwinned import com.tangem.domain.core.chain.Chain import com.tangem.domain.core.chain.ResultChain import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.AppState +import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.tap.features.onboarding.OnboardingHelper -import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.utils.extensions.DELAY_SDK_DIALOG_CLOSE import kotlinx.coroutines.delay -import org.rekotlin.Store /** * Handles the verification process to determine if the scanned card requires onboarding. @@ -22,19 +20,17 @@ import org.rekotlin.Store * Returns: * - [ScanChainException.OnboardingNeeded] if onboarding required. * - * @param store the [Store] that holds the state of the app. - * * @see Chain for more information about the Chain interface. */ class CheckForOnboardingChain( - private val store: Store, + private val trackingContextProxy: TrackingContextProxy, + private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase, + private val onboardingHelper: OnboardingHelper, ) : ResultChain() { override suspend fun launch(previousChainResult: ScanResponse): ScanChainResult { - val trackingContextProxy = store.inject(DaggerGraphState::trackingContextProxy) - return when { - OnboardingHelper.isOnboardingCase(previousChainResult) -> { + onboardingHelper.isOnboardingCase(previousChainResult) -> { trackingContextProxy.addContext(previousChainResult) ScanChainException.OnboardingNeeded( AppRoute.Onboarding( @@ -46,8 +42,7 @@ class CheckForOnboardingChain( else -> { trackingContextProxy.setContext(previousChainResult) - val wasTwinsOnboardingShown = store.inject(DaggerGraphState::wasTwinsOnboardingShownUseCase) - .invokeSync() + val wasTwinsOnboardingShown = wasTwinsOnboardingShownUseCase.invokeSync() // If twins was twinned previously but twins welcome not shown if (previousChainResult.twinsIsTwinned() && !wasTwinsOnboardingShown) { diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/DisclaimerChain.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/DisclaimerChain.kt index 7bd4be874c..93a1f9feac 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/DisclaimerChain.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/DisclaimerChain.kt @@ -2,33 +2,30 @@ package com.tangem.tap.domain.scanCard.chains import arrow.core.right import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.domain.card.ScanCardException +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.core.chain.Chain import com.tangem.domain.core.chain.ResultChain import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.dispatchNavigationAction -import com.tangem.tap.common.redux.AppState import com.tangem.tap.features.disclaimer.createDisclaimer -import org.rekotlin.Store /** * Handles disclaimer display after the card scanning operation. * - * Returns [ScanChainException.DisclaimerWasCanceled] if the disclaimer is dismissed by the user. - * - * @param store the [Store] that holds the state of the app, used here to dispatch actions related to disclaimers. * @param disclaimerWillShow an optional function to be invoked when a disclaimer is about to be shown. Default is an * empty function. * * @see Chain for more information about the Chain interface. */ internal class DisclaimerChain( - private val store: Store, + private val appRouter: AppRouter, + private val cardRepository: CardRepository, private val disclaimerWillShow: () -> Unit = {}, ) : ResultChain() { override suspend fun launch(previousChainResult: ScanResponse): ScanChainResult { - val disclaimer = previousChainResult.card.createDisclaimer() + val disclaimer = previousChainResult.card.createDisclaimer(cardRepository) return if (disclaimer.isAccepted()) { previousChainResult.right() @@ -36,9 +33,7 @@ internal class DisclaimerChain( disclaimerWillShow() // TODO: [REDACTED_JIRA] - store.dispatchNavigationAction { - push(route = AppRoute.Disclaimer(isTosAccepted = false)) - } + appRouter.push(route = AppRoute.Disclaimer(isTosAccepted = false)) previousChainResult.right() } diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/ScanChainException.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/ScanChainException.kt index cd7a62cab3..31c35c54de 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/ScanChainException.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/ScanChainException.kt @@ -5,15 +5,6 @@ import com.tangem.domain.card.ScanCardException sealed class ScanChainException : ScanCardException.ChainException() { - /** - * May be returned from [DisclaimerChain] - * */ - class DisclaimerWasCanceled : ScanChainException() { - - @Suppress("UnusedPrivateMember") - private fun readResolve(): Any = DisclaimerWasCanceled() - } - /** * May be returned from [CheckForOnboardingChain] * diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt index a7fd680230..711c8c9e8e 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/utils/ScanCardExceptionConverter.kt @@ -33,7 +33,6 @@ internal class ScanCardExceptionConverter : TwoWayConverter TangemSdkError.UserCancelled() is ScanChainException.OnboardingNeeded, null, -> TangemSdkError.ExceptionError(e?.cause) diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 29a181c404..6211ed6b11 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -23,6 +23,7 @@ import com.tangem.crypto.bip39.DefaultMnemonic import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.card.common.util.cardTypesResolver +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse @@ -72,6 +73,7 @@ internal class DefaultTangemSdkManager( private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, private val blockchainToDeriveFinder: BlockchainToDeriveFinder, private val analyticsErrorHandler: AnalyticsErrorHandler, + private val cardRepository: CardRepository, ) : TangemSdkManager { private val tangemSdk: TangemSdk @@ -146,6 +148,7 @@ internal class DefaultTangemSdkManager( shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated, isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled, onboardingV2FeatureToggles = onboardingV2FeatureToggles, + cardRepository = cardRepository, ), cardId = cardId, initialMessage = message, @@ -453,6 +456,7 @@ internal class DefaultTangemSdkManager( twinPublicKey = secondCardPublicKey, issuerKeys = issuerKeyPair, isDynamicAddressesEnabled = dynamicAddressesFeatureToggles.isDynamicAddressesEnabled, + cardRepository = cardRepository, ), cardId = cardId, initialMessage = initialMessage, diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt index 266bfaa86b..78d3151844 100644 --- a/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/tasks/product/ScanProductTask.kt @@ -21,6 +21,7 @@ import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins import com.tangem.domain.card.common.TapWorkarounds.isVisa import com.tangem.domain.card.common.TwinsHelper import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_IDS import com.tangem.domain.models.scan.CardDTO.Companion.RING_BATCH_PREFIX @@ -34,13 +35,10 @@ import com.tangem.operations.backup.StartPrimaryCardLinkingTask import com.tangem.operations.derivation.DeriveMultipleWalletPublicKeysTask import com.tangem.operations.files.ReadFilesTask import com.tangem.operations.issuerAndUserData.ReadIssuerDataCommand -import com.tangem.tap.common.extensions.inject import com.tangem.tap.domain.TapSdkError import com.tangem.tap.domain.visa.VisaCardScanHandler import com.tangem.tap.mainScope -import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.tap.scope -import com.tangem.tap.store import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -53,6 +51,7 @@ internal class ScanProductTask( private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles?, private val shouldCheckIsAlreadyActivated: Boolean, private val isDynamicAddressesEnabled: Boolean, + private val cardRepository: CardRepository, override val allowsRequestAccessCodeFromRepository: Boolean = false, ) : CardSessionRunnable { @@ -80,7 +79,11 @@ internal class ScanProductTask( readVisaCard( session = session, cardDto = cardDto, - scanWalletProcessor = ScanWalletProcessor(blockchainToDeriveFinder, isDynamicAddressesEnabled), + scanWalletProcessor = ScanWalletProcessor( + blockchainToDeriveFinder = blockchainToDeriveFinder, + isDynamicAddressesEnabled = isDynamicAddressesEnabled, + cardRepository = cardRepository, + ), callback = callback, ) return @@ -88,7 +91,11 @@ internal class ScanProductTask( val commandProcessor = when { cardDto.isTangemTwins -> ScanTwinProcessor() - else -> ScanWalletProcessor(blockchainToDeriveFinder, isDynamicAddressesEnabled) + else -> ScanWalletProcessor( + blockchainToDeriveFinder = blockchainToDeriveFinder, + isDynamicAddressesEnabled = isDynamicAddressesEnabled, + cardRepository = cardRepository, + ) } commandProcessor.proceed(cardDto, session) { processorResult -> when (processorResult) { @@ -114,7 +121,7 @@ internal class ScanProductTask( return if (shouldCheckIsAlreadyActivated) { PreflightReadMode.FullCardReadWithAccessCodeCheck } else { - return super.preflightReadMode() + super.preflightReadMode() } } @@ -171,6 +178,7 @@ internal class ScanProductTask( private class ScanWalletProcessor( private val blockchainToDeriveFinder: BlockchainToDeriveFinder?, private val isDynamicAddressesEnabled: Boolean, + private val cardRepository: CardRepository, ) : ProductCommandProcessor { var primaryCard: PrimaryCard? = null @@ -262,8 +270,7 @@ private class ScanWalletProcessor( callback: (result: CompletionResult) -> Unit, ) { mainScope.launch { - val isActivationInProgress = store.inject(DaggerGraphState::cardRepository) - .isActivationInProgress(card.cardId) + val isActivationInProgress = cardRepository.isActivationInProgress(card.cardId) @Suppress("ComplexCondition") if (card.backupStatus == CardDTO.BackupStatus.NoBackup && diff --git a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt index 548fadc6ad..c2c044dd95 100644 --- a/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt +++ b/app/src/main/java/com/tangem/tap/domain/twins/FinalizeTwinTask.kt @@ -4,6 +4,7 @@ import com.tangem.common.CompletionResult import com.tangem.common.KeyPair import com.tangem.common.core.CardSession import com.tangem.common.core.CardSessionRunnable +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.ScanResponse import com.tangem.operations.PreflightReadMode import com.tangem.operations.PreflightReadTask @@ -13,6 +14,7 @@ class FinalizeTwinTask( private val twinPublicKey: ByteArray, private val issuerKeys: KeyPair, private val isDynamicAddressesEnabled: Boolean, + private val cardRepository: CardRepository, ) : CardSessionRunnable { override val allowsRequestAccessCodeFromRepository: Boolean = false @@ -35,6 +37,7 @@ class FinalizeTwinTask( shouldCheckIsAlreadyActivated = false, isDynamicAddressesEnabled = isDynamicAddressesEnabled, onboardingV2FeatureToggles = null, + cardRepository = cardRepository, ).run(session, callback) is CompletionResult.Failure -> callback(CompletionResult.Failure(readResult.error)) diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt index 90f73cb4ad..2f44bf9dd0 100644 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt +++ b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt @@ -1,18 +1,15 @@ package com.tangem.tap.features.disclaimer +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.CardDTO -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store import java.util.Locale -fun CardDTO.createDisclaimer(): Disclaimer { - val dataProvider = provideDisclaimerDataProvider(cardId) +fun CardDTO.createDisclaimer(cardRepository: CardRepository): Disclaimer { + val dataProvider = provideDisclaimerDataProvider(cardId, cardRepository) return TangemDisclaimer(dataProvider) } -private fun provideDisclaimerDataProvider(cardId: String): DisclaimerDataProvider { - val cardRepository = store.inject(DaggerGraphState::cardRepository) +private fun provideDisclaimerDataProvider(cardId: String, cardRepository: CardRepository): DisclaimerDataProvider { return object : DisclaimerDataProvider { override fun getLanguage(): String = Locale.getDefault().language override fun getCardId(): String = cardId diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index b90a6fc412..5fb9bba066 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -40,7 +40,6 @@ import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase import com.tangem.feature.swap.analytics.StoriesEvents import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.tap.network.exchangeServices.SellService -import com.tangem.tap.proxy.AppStateHolder import com.tangem.tap.routing.configurator.AppRouterConfig import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -77,7 +76,6 @@ internal class MainViewModel @Inject constructor( private val sendPushTokenUseCase: SendPushTokenUseCase, private val apiConfigsManager: ApiConfigsManager, private val multiQuoteUpdater: MultiQuoteUpdater, - private val appStateHolder: AppStateHolder, private val appRouterConfig: AppRouterConfig, private val sellService: SellService, private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider, @@ -176,8 +174,6 @@ internal class MainViewModel @Inject constructor( private fun initializeOffRamp() { viewModelScope.launch { - appStateHolder.sellService = sellService - sellService.update() } } diff --git a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt index 521d22b51a..757f98522c 100644 --- a/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt +++ b/app/src/main/java/com/tangem/tap/features/onboarding/OnboardingHelper.kt @@ -2,19 +2,18 @@ package com.tangem.tap.features.onboarding import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.common.util.twinsIsTwinned +import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject import com.tangem.tap.features.demo.DemoHelper -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store +import javax.inject.Inject +import javax.inject.Singleton -/** -[REDACTED_AUTHOR] - */ -object OnboardingHelper { +@Singleton +class OnboardingHelper @Inject constructor( + private val cardRepository: CardRepository, +) { suspend fun isOnboardingCase(response: ScanResponse): Boolean { - val cardRepository = store.inject(DaggerGraphState::cardRepository) val cardId = response.card.cardId return when { @@ -22,7 +21,7 @@ object OnboardingHelper { // if (response.visaCardActivationStatus == null) error("Visa card activation status is null") // // response.visaCardActivationStatus !is VisaCardActivationStatus.Activated - return true + true } response.cardTypesResolver.isTangemTwins() -> { From 9736fa301caaa1637ba17a1d2651e1afdd801e0a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 12:23:19 +0100 Subject: [PATCH 087/206] Updated on 2026-08-14 --- .../CreateWalletStartModel.kt | 2 +- .../DefaultAddressSyncComponent.kt | 39 +++++++++---------- .../v2/addresssync/model/AddressSyncIntent.kt | 2 +- .../v2/addresssync/model/AddressSyncModel.kt | 12 +++--- .../v2/addresssync/ui/AddressSyncContent.kt | 7 +--- .../addresssync/model/AddressSyncModelTest.kt | 10 ++--- 6 files changed, 33 insertions(+), 39 deletions(-) diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index b438774c00..8c15de536f 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -239,7 +239,7 @@ internal class CreateWalletStartModel @Inject constructor( val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { AppRoute.Onboarding( scanResponse = scanResponse, - mode = AppRoute.Onboarding.Mode.AddressSync + mode = AppRoute.Onboarding.Mode.AddressSync, ) } else { AppRoute.Wallet diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index 8357eb37d4..255027abc8 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -52,11 +52,11 @@ internal class DefaultAddressSyncComponent( modifier = modifier, childContent = { Children( - stack = childStack - ) { - it.instance.Content(modifier = modifier) + stack = childStack, + ) { child -> + child.instance.Content(Modifier) } - } + }, ) BackHandler { @@ -64,10 +64,7 @@ internal class DefaultAddressSyncComponent( } } - private fun createChild( - step: AddressSyncStep, - childContext: AppComponentContext, - ): ComposableContentComponent { + private fun createChild(step: AddressSyncStep, childContext: AppComponentContext): ComposableContentComponent { return when (step) { AddressSyncStep.ASK_BIOMETRY -> createAskBiometryComponent(childContext) AddressSyncStep.ASK_NOTIFICATIONS -> createPushNotificationComponent(childContext) @@ -85,8 +82,8 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ASK_NOTIFICATIONS, - replace = true - ) + shouldReplace = true, + ), ) } @@ -94,12 +91,12 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ASK_NOTIFICATIONS, - replace = false - ) + shouldReplace = false, + ), ) } - } - ) + }, + ), ) } @@ -112,8 +109,8 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ADDRESS_SYNC, - replace = true - ) + shouldReplace = true, + ), ) } @@ -121,8 +118,8 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ADDRESS_SYNC, - replace = false - ) + shouldReplace = false, + ), ) } @@ -130,13 +127,13 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ADDRESS_SYNC, - replace = false - ) + shouldReplace = false, + ), ) } }, source = AppRoute.PushNotification.Source.Onboarding, - ) + ), ) } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt index 47ecbbb69d..1293c244d5 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt @@ -3,6 +3,6 @@ package com.tangem.features.onboarding.v2.addresssync.model import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep sealed interface AddressSyncIntent { - data class Next(val step: AddressSyncStep, val replace: Boolean) : AddressSyncIntent + data class Next(val step: AddressSyncStep, val shouldReplace: Boolean) : AddressSyncIntent data object Back : AddressSyncIntent } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt index 773be2996e..b06e54736c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -47,15 +47,15 @@ internal class AddressSyncModel @Inject constructor( private suspend fun trySkippingScreen(next: AddressSyncIntent.Next) { when (next.step) { AddressSyncStep.ASK_BIOMETRY -> { - val showBiometry = canUseBiometryUseCase.strict() && shouldShowAskBiometryUseCase() - if (showBiometry.not()) { - nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, replace = true)) + val shouldShowAskBiometry = canUseBiometryUseCase.strict() && shouldShowAskBiometryUseCase() + if (shouldShowAskBiometry.not()) { + nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, shouldReplace = true)) } } AddressSyncStep.ASK_NOTIFICATIONS -> { - val showNotification = shouldAskPermissionUseCase(PUSH_PERMISSION) - if (showNotification.not()) { - nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ADDRESS_SYNC, replace = true)) + val shouldShowAskNotification = shouldAskPermissionUseCase(PUSH_PERMISSION) + if (shouldShowAskNotification.not()) { + nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ADDRESS_SYNC, shouldReplace = true)) } } AddressSyncStep.ADDRESS_SYNC -> Unit diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt index 3da4b6d12e..9c0c693d4e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncContent.kt @@ -14,10 +14,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @Composable -internal fun AddressSyncContent( - modifier: Modifier = Modifier, - childContent: @Composable (Modifier) -> Unit = {}, -) { +internal fun AddressSyncContent(modifier: Modifier = Modifier, childContent: @Composable (Modifier) -> Unit = {}) { Column( modifier = modifier .background(color = TangemTheme.colors.background.secondary) @@ -26,7 +23,7 @@ internal fun AddressSyncContent( .systemBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - childContent(modifier) + childContent(Modifier) } } diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt index 5f5797cc29..0e4a8acc7d 100644 --- a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -39,7 +39,7 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, replace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, shouldReplace = false)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ASK_BIOMETRY)) @@ -55,7 +55,7 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, replace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, shouldReplace = false)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS)) @@ -70,7 +70,7 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, replace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, shouldReplace = false)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC)) @@ -83,7 +83,7 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, replace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, shouldReplace = false)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS)) @@ -96,7 +96,7 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, replace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, shouldReplace = false)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC)) From cc78c7dde56abc1c037ba41d564f699b2f4e98dc Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 16:20:46 +0400 Subject: [PATCH 088/206] Updated on 2026-08-14 --- .../tangem/data/account/converter/AccountConvertersExt.kt | 6 +++++- .../kotlin/com/tangem/domain/models/account/AccountId.kt | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt index 3d35e7327c..312e189d67 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/converter/AccountConvertersExt.kt @@ -1,6 +1,7 @@ package com.tangem.data.account.converter import arrow.core.getOrElse +import arrow.core.right import com.tangem.datasource.api.tangemTech.models.account.WalletAccountDTO import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.CryptoPortfolioIcon @@ -8,7 +9,10 @@ import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.wallet.UserWalletId internal fun String.toAccountId(userWalletId: UserWalletId): AccountId { - return AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId).getOrElse { + return when { + startsWith(AccountId.PaymentAccountIdPrefix) -> AccountId.forPaymentAccount(userWalletId).right() + else -> AccountId.forCryptoPortfolio(value = this, userWalletId = userWalletId) + }.getOrElse { error("Unable to create AccountId from value: $this. Cause: $it") } } diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt index 0c65375595..d4c73a73ef 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/AccountId.kt @@ -37,6 +37,8 @@ data class AccountId private constructor( companion object { + const val PaymentAccountIdPrefix = "payment_" + private val sha256Digest: MessageDigest by lazy { MessageDigest.getInstance("SHA-256") } private val hexRegex = Regex("^[a-fA-F0-9]{64}$") @@ -73,7 +75,7 @@ data class AccountId private constructor( } fun forPaymentAccount(userWalletId: UserWalletId): AccountId { - return AccountId(value = "payment_$userWalletId", userWalletId = userWalletId) + return AccountId(value = "$PaymentAccountIdPrefix$userWalletId", userWalletId = userWalletId) } } } \ No newline at end of file From 6d37d90a86d4c24afba802e5d6a00f4e1ec7b7e0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 16:29:43 +0400 Subject: [PATCH 089/206] Updated on 2026-08-14 --- .../account/di/AccountFeatureModule.kt | 17 -------- .../selector/di/PortfolioSelectorModule.kt | 20 --------- features/common-features/api/build.gradle.kts | 3 +- .../addtoportfolio/AddToPortfolioManager.kt | 2 +- .../portfolioselector}/PortfolioFetcher.kt | 2 +- .../PortfolioSelectorComponent.kt | 2 +- .../common-features/impl/build.gradle.kts | 1 + .../AddToPortfolioBottomSheet.kt | 2 +- .../DefaultAddToPortfolioComponent.kt | 2 +- ...tAddToPortfolioPreselectedDataComponent.kt | 2 +- .../converter/AvailableToAddDataConverter.kt | 2 +- .../model/AddToPortfolioModel.kt | 4 +- .../AddToPortfolioPreselectedDataModel.kt | 5 +-- .../ui/DefaultAddToPortfolioManager.kt | 2 +- .../DefaultPortfolioSelectorComponent.kt | 8 ++-- .../DefaultPortfolioSelectorController.kt | 6 +-- .../PortfolioSelectorModel.kt | 12 +++--- .../di/PortfolioSelectorModule.kt | 43 +++++++++++++++++++ .../entity/PortfolioSelectorUM.kt | 2 +- .../fetcher/DefaultPortfolioFetcher.kt | 6 +-- .../ui/PortfolioSelectorBS.kt | 6 +-- .../ui/PortfolioSelectorContent.kt | 20 ++++----- features/nft/impl/build.gradle.kts | 2 +- .../nft/common/DefaultNFTComponent.kt | 6 +-- features/onramp/impl/build.gradle.kts | 4 +- .../hottokens/DefaultHotCryptoComponent.kt | 2 +- .../onramp/hottokens/model/HotCryptoModel.kt | 4 +- features/referral/impl/build.gradle.kts | 2 +- .../referral/DefaultReferralComponent.kt | 4 +- .../feature/referral/model/ReferralModel.kt | 10 ++--- features/swap-v2/impl/build.gradle.kts | 1 + features/wallet/impl/build.gradle.kts | 1 + .../AddAndManageBottomSheetComponent.kt | 2 +- .../managetokens/model/AddAndManageModel.kt | 6 +-- .../wallet/child/wallet/WalletComponent.kt | 2 +- features/walletconnect/impl/build.gradle.kts | 2 +- .../connections/components/WcPairComponent.kt | 2 +- .../connections/model/WcPairModel.kt | 6 +-- .../routing/DefaultWcRoutingComponent.kt | 2 +- 39 files changed, 114 insertions(+), 113 deletions(-) delete mode 100644 features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt rename features/{account/api/src/main/java/com/tangem/features/account => common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector}/PortfolioFetcher.kt (96%) rename features/{account/api/src/main/java/com/tangem/features/account => common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector}/PortfolioSelectorComponent.kt (97%) rename features/{account/impl/src/main/java/com/tangem/features/account/selector => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector}/DefaultPortfolioSelectorComponent.kt (85%) rename features/{account/impl/src/main/java/com/tangem/features/account/selector => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector}/DefaultPortfolioSelectorController.kt (90%) rename features/{account/impl/src/main/java/com/tangem/features/account/selector => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector}/PortfolioSelectorModel.kt (95%) create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/di/PortfolioSelectorModule.kt rename features/{account/impl/src/main/java/com/tangem/features/account/selector => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector}/entity/PortfolioSelectorUM.kt (90%) rename features/{account/impl/src/main/java/com/tangem/features/account => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector}/fetcher/DefaultPortfolioFetcher.kt (94%) rename features/{account/impl/src/main/java/com/tangem/features/account/selector => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector}/ui/PortfolioSelectorBS.kt (91%) rename features/{account/impl/src/main/java/com/tangem/features/account/selector => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector}/ui/PortfolioSelectorContent.kt (91%) diff --git a/features/account/impl/src/main/java/com/tangem/features/account/di/AccountFeatureModule.kt b/features/account/impl/src/main/java/com/tangem/features/account/di/AccountFeatureModule.kt index 9f77e55922..29320dd427 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/di/AccountFeatureModule.kt +++ b/features/account/impl/src/main/java/com/tangem/features/account/di/AccountFeatureModule.kt @@ -3,15 +3,9 @@ package com.tangem.features.account.di import com.tangem.features.account.AccountCreateEditComponent import com.tangem.features.account.AccountDetailsComponent import com.tangem.features.account.ArchivedAccountListComponent -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.PortfolioSelectorController import com.tangem.features.account.archived.DefaultArchivedAccountListComponent import com.tangem.features.account.createedit.DefaultAccountCreateEditComponent import com.tangem.features.account.details.DefaultAccountDetailsComponent -import com.tangem.features.account.fetcher.DefaultPortfolioFetcher -import com.tangem.features.account.selector.DefaultPortfolioSelectorComponent -import com.tangem.features.account.selector.DefaultPortfolioSelectorController import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -21,17 +15,6 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) internal interface AccountFeatureModule { - @Binds - fun bindPortfolioFetcherFactory(impl: DefaultPortfolioFetcher.Factory): PortfolioFetcher.Factory - - @Binds - fun bindPortfolioSelectorController(impl: DefaultPortfolioSelectorController): PortfolioSelectorController - - @Binds - fun bindPortfolioSelectorComponentFactory( - impl: DefaultPortfolioSelectorComponent.Factory, - ): PortfolioSelectorComponent.Factory - @Binds fun bindAccountCreateEditComponentFactory( impl: DefaultAccountCreateEditComponent.Factory, diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt b/features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt deleted file mode 100644 index d28a1c305e..0000000000 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/di/PortfolioSelectorModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.features.account.selector.di - -import com.tangem.core.decompose.di.ModelComponent -import com.tangem.core.decompose.model.Model -import com.tangem.features.account.selector.PortfolioSelectorModel -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.multibindings.ClassKey -import dagger.multibindings.IntoMap - -@Module -@InstallIn(ModelComponent::class) -internal interface PortfolioSelectorModule { - - @Binds - @IntoMap - @ClassKey(PortfolioSelectorModel::class) - fun portfolioSelectorModel(model: PortfolioSelectorModel): Model -} \ No newline at end of file diff --git a/features/common-features/api/build.gradle.kts b/features/common-features/api/build.gradle.kts index f46795f50f..2d20ac5683 100644 --- a/features/common-features/api/build.gradle.kts +++ b/features/common-features/api/build.gradle.kts @@ -10,12 +10,11 @@ android { } dependencies { - /** Api */ // todo swap delete after move portfolio selector - implementation(projects.features.account.api) /* Project - Domain */ implementation(projects.domain.models) implementation(projects.domain.markets) + implementation(projects.domain.account) /* Project - Core */ implementation(projects.core.decompose) diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt index 9ab9c5a86c..e1d58b5033 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt @@ -5,7 +5,7 @@ import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel diff --git a/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioFetcher.kt similarity index 96% rename from features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioFetcher.kt index a56d06a5dd..2428a8c030 100644 --- a/features/account/api/src/main/java/com/tangem/features/account/PortfolioFetcher.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioFetcher.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account +package com.tangem.features.commonfeatures.api.portfolioselector import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.appcurrency.model.AppCurrency diff --git a/features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt similarity index 97% rename from features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt index 7d8627e929..8d658a65ac 100644 --- a/features/account/api/src/main/java/com/tangem/features/account/PortfolioSelectorComponent.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/portfolioselector/PortfolioSelectorComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account +package com.tangem.features.commonfeatures.api.portfolioselector import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent diff --git a/features/common-features/impl/build.gradle.kts b/features/common-features/impl/build.gradle.kts index 01aef9c2e7..afa9fcfb58 100644 --- a/features/common-features/impl/build.gradle.kts +++ b/features/common-features/impl/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(projects.features.commonFeatures.api) // todo swap delete after move portfolio selector implementation(projects.features.account.api) + implementation(projects.features.wallet.api) implementation(projects.features.tokenRecieve.api) /** Core modules */ diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt index c649a267f2..0a20db5947 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt @@ -20,7 +20,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes @Composable diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt index 6ce1279b9d..d69a90e75d 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt @@ -11,7 +11,7 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt index f298a48b32..7ba255d6e8 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt @@ -9,7 +9,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioPreselectedDataModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt index 32f382fdcb..fd5664787d 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt @@ -12,7 +12,7 @@ import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index a0b119a4ab..18071b731c 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -19,8 +19,8 @@ import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.features.commonfeatures.api.addtoportfolio.* import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt index 10e935ada2..a2a388b7b2 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt @@ -20,8 +20,8 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent import com.tangem.features.commonfeatures.api.addtoportfolio.* import com.tangem.features.commonfeatures.impl.R @@ -33,7 +33,6 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import java.math.BigDecimal import javax.inject.Inject -import kotlin.collections.get import kotlin.collections.mapNotNull @Suppress("LongParameterList") diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt index a0aa017d17..e0f328c406 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt @@ -2,7 +2,7 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.ui import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams -import com.tangem.features.account.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.Settings diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt similarity index 85% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt index 2f0229f8f3..b76d6089cc 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.selector +package com.tangem.features.commonfeatures.impl.portfolioselector import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -7,9 +7,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.extensions.TextReference -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.selector.ui.PortfolioSelectorBS -import com.tangem.features.account.selector.ui.PortfolioSelectorContent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorBS +import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorContent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorController.kt similarity index 90% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorController.kt index e2da9828c4..69065a9436 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/DefaultPortfolioSelectorController.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorController.kt @@ -1,12 +1,12 @@ -package com.tangem.features.account.selector +package com.tangem.features.commonfeatures.impl.portfolioselector import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt similarity index 95% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt index 58b5fd61ca..deb65e62a0 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/PortfolioSelectorModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/PortfolioSelectorModel.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.selector +package com.tangem.features.commonfeatures.impl.portfolioselector import com.tangem.common.ui.account.AccountPortfolioItemUMConverter import com.tangem.common.ui.userwallet.converter.UserWalletItemUMConverter @@ -15,11 +15,11 @@ import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.impl.R -import com.tangem.features.account.selector.entity.PortfolioSelectorItemUM -import com.tangem.features.account.selector.entity.PortfolioSelectorUM +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorItemUM +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM import com.tangem.features.wallet.utils.UserWalletImageFetcher import com.tangem.operations.attestation.ArtworkSize import com.tangem.utils.coroutines.CoroutineDispatcherProvider diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/di/PortfolioSelectorModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/di/PortfolioSelectorModule.kt new file mode 100644 index 0000000000..976eeb78d1 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/di/PortfolioSelectorModule.kt @@ -0,0 +1,43 @@ +package com.tangem.features.commonfeatures.impl.portfolioselector.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController +import com.tangem.features.commonfeatures.impl.portfolioselector.DefaultPortfolioSelectorComponent +import com.tangem.features.commonfeatures.impl.portfolioselector.DefaultPortfolioSelectorController +import com.tangem.features.commonfeatures.impl.portfolioselector.PortfolioSelectorModel +import com.tangem.features.commonfeatures.impl.portfolioselector.fetcher.DefaultPortfolioFetcher +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface PortfolioSelectorModule { + + @Binds + @IntoMap + @ClassKey(PortfolioSelectorModel::class) + fun portfolioSelectorModel(model: PortfolioSelectorModel): Model +} + +@Module +@InstallIn(SingletonComponent::class) +internal interface PortfolioSelectorSingletonModule { + + @Binds + fun bindPortfolioFetcherFactory(impl: DefaultPortfolioFetcher.Factory): PortfolioFetcher.Factory + + @Binds + fun bindPortfolioSelectorController(impl: DefaultPortfolioSelectorController): PortfolioSelectorController + + @Binds + fun bindPortfolioSelectorComponentFactory( + impl: DefaultPortfolioSelectorComponent.Factory, + ): PortfolioSelectorComponent.Factory +} \ No newline at end of file diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/entity/PortfolioSelectorUM.kt similarity index 90% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/entity/PortfolioSelectorUM.kt index 83d3979381..109e9e4fd1 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/entity/PortfolioSelectorUM.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/entity/PortfolioSelectorUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.selector.entity +package com.tangem.features.commonfeatures.impl.portfolioselector.entity import androidx.compose.runtime.Immutable import com.tangem.common.ui.userwallet.state.UserWalletItemUM diff --git a/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/fetcher/DefaultPortfolioFetcher.kt similarity index 94% rename from features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/fetcher/DefaultPortfolioFetcher.kt index 9fb0924c4a..5bb30cc8f2 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/fetcher/DefaultPortfolioFetcher.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/fetcher/DefaultPortfolioFetcher.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.fetcher +package com.tangem.features.commonfeatures.impl.portfolioselector.fetcher import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer @@ -9,8 +9,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioFetcher.* +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt similarity index 91% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt index 5e63cd75cc..cd69452b68 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorBS.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.selector.ui +package com.tangem.features.commonfeatures.impl.portfolioselector.ui import android.content.res.Configuration import androidx.compose.foundation.layout.PaddingValues @@ -14,8 +14,8 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.account.impl.R -import com.tangem.features.account.selector.entity.PortfolioSelectorUM +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM @Composable internal fun PortfolioSelectorBS( diff --git a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt similarity index 91% rename from features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt index 6b8f1872fd..d739e4d5a9 100644 --- a/features/account/impl/src/main/java/com/tangem/features/account/selector/ui/PortfolioSelectorContent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.account.selector.ui +package com.tangem.features.commonfeatures.impl.portfolioselector.ui import android.content.res.Configuration import androidx.compose.foundation.BorderStroke @@ -32,13 +32,9 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.account.impl.R -import com.tangem.features.account.selector.entity.PortfolioSelectorItemUM -import com.tangem.features.account.selector.entity.PortfolioSelectorUM -import com.tangem.features.account.selector.ui.PortfolioSelectorPreviewData.firstList -import com.tangem.features.account.selector.ui.PortfolioSelectorPreviewData.lockedWalletList -import com.tangem.features.account.selector.ui.PortfolioSelectorPreviewData.secondList -import com.tangem.features.account.selector.ui.PortfolioSelectorPreviewData.walletList +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorItemUM +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM import kotlinx.collections.immutable.toImmutableList import java.util.UUID @@ -207,19 +203,19 @@ internal class PortfolioSelectorPreviewStateProvider : CollectionPreviewParamete listOf( PortfolioSelectorUM( title = resourceReference(R.string.common_choose_account), - items = firstList.toImmutableList(), + items = PortfolioSelectorPreviewData.firstList.toImmutableList(), ), PortfolioSelectorUM( title = resourceReference(R.string.common_choose_account), - items = secondList.toImmutableList(), + items = PortfolioSelectorPreviewData.secondList.toImmutableList(), ), PortfolioSelectorUM( title = resourceReference(R.string.common_choose_wallet), - items = walletList.toImmutableList(), + items = PortfolioSelectorPreviewData.walletList.toImmutableList(), ), PortfolioSelectorUM( title = resourceReference(R.string.common_choose_wallet), - items = lockedWalletList.toImmutableList(), + items = PortfolioSelectorPreviewData.lockedWalletList.toImmutableList(), ), ), ) \ No newline at end of file diff --git a/features/nft/impl/build.gradle.kts b/features/nft/impl/build.gradle.kts index be33a37a7f..797564dd0e 100644 --- a/features/nft/impl/build.gradle.kts +++ b/features/nft/impl/build.gradle.kts @@ -13,7 +13,7 @@ android { dependencies { /** Api */ - implementation(projects.features.account.api) + implementation(projects.features.commonFeatures.api) implementation(projects.features.nft.api) implementation(projects.features.tokenRecieve.api) diff --git a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt index 25d54e0d9f..2d94110b50 100644 --- a/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt +++ b/features/nft/impl/src/main/kotlin/com/tangem/features/nft/common/DefaultNFTComponent.kt @@ -20,9 +20,9 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.nft.collections.NFTCollectionsComponent import com.tangem.features.nft.common.ui.NFTContent import com.tangem.features.nft.component.NFTComponent diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 4d66407671..60286429b9 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -12,11 +12,9 @@ android { } dependencies { - /** Api */ - implementation(projects.features.commonFeatures.api) /** Project - API */ - implementation(projects.features.account.api) + implementation(projects.features.commonFeatures.api) implementation(projects.features.onramp.api) implementation(projects.features.swap.api) implementation(projects.features.swap.domain) diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt index 1112152d60..50fb0e3040 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/DefaultHotCryptoComponent.kt @@ -31,7 +31,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.onramp.hottokens.model.HotCryptoModel import com.tangem.features.onramp.hottokens.portfolio.OnrampAddToPortfolioComponent import com.tangem.features.onramp.hottokens.portfolio.OnrampAddTokenComponent diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt index cf1badae14..e25061f64d 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/hottokens/model/HotCryptoModel.kt @@ -22,8 +22,8 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.onramp.model.HotCryptoCurrency -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.features.onramp.hottokens.HotCryptoComponent import com.tangem.features.onramp.hottokens.converter.HotTokenItemStateConverter import com.tangem.features.onramp.hottokens.entity.HotCryptoUM diff --git a/features/referral/impl/build.gradle.kts b/features/referral/impl/build.gradle.kts index 8f23358f8f..626b3f81d3 100644 --- a/features/referral/impl/build.gradle.kts +++ b/features/referral/impl/build.gradle.kts @@ -13,7 +13,7 @@ android { dependencies { /** Api */ api(projects.features.referral.api) - api(projects.features.account.api) + api(projects.features.commonFeatures.api) /** Core modules */ implementation(projects.core.analytics) diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt index d85d5291f1..fba435fa4c 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/DefaultReferralComponent.kt @@ -13,16 +13,16 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.feature.referral.api.ReferralComponent import com.tangem.feature.referral.model.ReferralModel import com.tangem.feature.referral.ui.ReferralScreen -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.serialization.builtins.serializer class DefaultReferralComponent @AssistedInject constructor( - private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, @Assisted appComponentContext: AppComponentContext, @Assisted params: ReferralComponent.Params, + private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, ) : ReferralComponent, AppComponentContext by appComponentContext { private val model: ReferralModel = getOrCreateModel(params) diff --git a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt index f928bf5249..6bf34b1050 100644 --- a/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt +++ b/features/referral/impl/src/main/java/com/tangem/feature/referral/model/ReferralModel.kt @@ -37,9 +37,9 @@ import com.tangem.feature.referral.domain.models.ReferralInfo import com.tangem.feature.referral.models.DemoModeException import com.tangem.feature.referral.models.ReferralStateHolder import com.tangem.feature.referral.models.ReferralStateHolder.* -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* @@ -53,14 +53,14 @@ import javax.inject.Inject internal class ReferralModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + val portfolioSelectorController: PortfolioSelectorController, + private val portfolioFetcherFactory: PortfolioFetcher.Factory, private val referralInteractor: ReferralInteractor, private val analyticsEventHandler: AnalyticsEventHandler, private val shareManager: ShareManager, private val urlOpener: UrlOpener, private val getUserWalletUseCase: GetUserWalletUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, - private val portfolioFetcherFactory: PortfolioFetcher.Factory, - val portfolioSelectorController: PortfolioSelectorController, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, diff --git a/features/swap-v2/impl/build.gradle.kts b/features/swap-v2/impl/build.gradle.kts index 76f57cbad9..0b55164642 100644 --- a/features/swap-v2/impl/build.gradle.kts +++ b/features/swap-v2/impl/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(projects.features.swapV2.api) implementation(projects.features.manageTokens.api) implementation(projects.features.sendV2.api) + implementation(projects.features.commonFeatures.api) /** Core */ implementation(projects.core.decompose) diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index c91c083d7d..60c99dd9a8 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -128,6 +128,7 @@ dependencies { implementation(projects.domain.assetsdiscovery) /** Feature Apis */ + implementation(projects.features.commonFeatures.api) implementation(projects.features.account.api) implementation(projects.features.details.api) implementation(projects.features.hotWallet.api) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt index 3cd335e5c5..22715d7bb5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/AddAndManageBottomSheetComponent.kt @@ -13,7 +13,7 @@ import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.wallet.child.managetokens.model.AddAndManageModel import com.tangem.feature.wallet.child.managetokens.ui.AddAndManageBottomSheetContent -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import kotlinx.serialization.builtins.serializer internal class AddAndManageBottomSheetComponent( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt index 13df43dd76..9600a357f8 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt @@ -8,9 +8,9 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.models.account.AccountId import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index 72e1976879..d6f6a2e7bc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -25,7 +25,7 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.tokens.model.details.TokenAction import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.feature.wallet.child.tokenActions.DefaultTokenActionsComponent import com.tangem.feature.wallet.child.tokenActions.TokenActionsComponent import com.tangem.feature.wallet.child.wallet.model.WalletModel diff --git a/features/walletconnect/impl/build.gradle.kts b/features/walletconnect/impl/build.gradle.kts index 3c857c76b5..2548c1f7b0 100644 --- a/features/walletconnect/impl/build.gradle.kts +++ b/features/walletconnect/impl/build.gradle.kts @@ -12,7 +12,7 @@ android { } dependencies { - implementation(projects.features.account.api) + implementation(projects.features.commonFeatures.api) implementation(projects.features.wallet.api) implementation(projects.features.walletconnect.api) implementation(projects.features.sendV2.api) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt index a160555493..0ab64bd444 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/components/WcPairComponent.kt @@ -16,7 +16,7 @@ import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheetUM import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.walletconnect.model.WcPairRequest -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.walletconnect.connections.model.WcPairModel import com.tangem.features.walletconnect.connections.routes.WcAppInfoRoutes import com.tangem.features.walletconnect.connections.routes.WcAppInfoRoutes.Alert diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt index d51b17e8c5..c6c3e4f486 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/model/WcPairModel.kt @@ -34,9 +34,9 @@ import com.tangem.domain.walletconnect.model.WcSessionProposal import com.tangem.domain.walletconnect.model.sdkcopy.WcAppMetaData import com.tangem.domain.walletconnect.usecase.pair.WcPairState import com.tangem.domain.walletconnect.usecase.pair.WcPairUseCase -import com.tangem.features.account.PortfolioFetcher -import com.tangem.features.account.PortfolioSelectorComponent -import com.tangem.features.account.PortfolioSelectorController +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.features.walletconnect.connections.components.WcPairComponent import com.tangem.features.walletconnect.connections.components.WcSelectNetworksComponent import com.tangem.features.walletconnect.connections.entity.WcAppInfoUM diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt index 5b44bc3973..ca9fe402ed 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt @@ -14,7 +14,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.account.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.send.v2.api.FeeSelectorBlockComponent import com.tangem.features.send.v2.api.FeeSelectorComponent import com.tangem.features.walletconnect.components.WcRoutingComponent From 5cef4d684f5cfbf8b41f00ef0e432f00ea22dd52 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 18:22:32 +0400 Subject: [PATCH 090/206] Updated on 2026-08-14 --- app/build.gradle.kts | 1 - app/libs/rekotlin-1.0.4.jar | Bin 33612 -> 0 bytes .../com/tangem/tap/ApplicationEntryPoint.kt | 99 ---------- .../java/com/tangem/tap/TangemApplication.kt | 176 ------------------ .../com/tangem/tap/common/extensions/Store.kt | 62 ------ .../AccessCodeRequestPolicyMiddleware.kt | 33 ---- .../com/tangem/tap/common/redux/AppReducer.kt | 18 -- .../com/tangem/tap/common/redux/AppState.kt | 24 --- .../redux/LockUserWalletsTimerMiddleware.kt | 15 -- .../tangem/tap/common/redux/LogMiddleware.kt | 16 -- .../tap/common/redux/global/GlobalAction.kt | 11 -- .../tap/common/redux/global/GlobalReducer.kt | 18 -- .../tap/common/redux/global/GlobalState.kt | 14 -- .../com/tangem/tap/di/AppStateHolderModule.kt | 18 -- .../com/tangem/tap/domain/TapWalletManager.kt | 54 ------ .../com/tangem/tap/domain/model/Currency.kt | 5 +- .../tap/features/demo/DemoMiddleware.kt | 12 -- .../com/tangem/tap/proxy/AppStateHolder.kt | 33 ---- .../com/tangem/tap/proxy/di/ProxyModule.kt | 19 -- .../tap/proxy/redux/DaggerGraphAction.kt | 13 -- .../tap/proxy/redux/DaggerGraphMiddleware.kt | 13 -- .../tap/proxy/redux/DaggerGraphReducer.kt | 21 --- .../tap/proxy/redux/DaggerGraphState.kt | 78 -------- core/navigation/build.gradle.kts | 1 - domain/legacy/build.gradle.kts | 1 - .../tangem/domain/redux/ReduxStateHolder.kt | 13 -- domain/tokens/build.gradle.kts | 1 - features/details/impl/build.gradle.kts | 1 - features/manage-tokens/impl/build.gradle.kts | 1 - features/onramp/impl/build.gradle.kts | 1 - features/tokendetails/impl/build.gradle.kts | 1 - .../wallet-settings/impl/build.gradle.kts | 1 - features/wallet/impl/build.gradle.kts | 1 - gradle/dependencies.toml | 2 - 34 files changed, 2 insertions(+), 775 deletions(-) delete mode 100644 app/libs/rekotlin-1.0.4.jar delete mode 100644 app/src/main/java/com/tangem/tap/common/extensions/Store.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/AppState.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/LockUserWalletsTimerMiddleware.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt delete mode 100644 app/src/main/java/com/tangem/tap/di/AppStateHolderModule.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/demo/DemoMiddleware.kt delete mode 100644 app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt delete mode 100644 app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt delete mode 100644 app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt delete mode 100644 app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt delete mode 100644 app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt delete mode 100644 app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt delete mode 100644 domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6f398fb14f..8bc51a5ecd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -394,7 +394,6 @@ dependencies { implementation(deps.viewBindingDelegate) implementation(deps.armadillo) implementation(deps.kotlin.serialization) - implementation(deps.reKotlin) implementation(deps.reownCore) implementation(deps.reownWeb3) implementation(deps.prettyLogger) diff --git a/app/libs/rekotlin-1.0.4.jar b/app/libs/rekotlin-1.0.4.jar deleted file mode 100644 index 3221e554bec48e962f07ac64728e414a2a3a5820..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33612 zcmbTdWpG?umMm;bvam!Jv&GEJ%*?WwO3ci%n3oSFX&JS#pw~V9;N_Ktg_Tb83+J@_B*$*VpF-_4$z&R^q1?ml2@_{UZ05 zg|*%w=UAT$Fg`!X|GrR~Uq)O+SW$^aTI5E0Y*b2;nr0eSlA2;-Y_d*)ZjSNK-u52I zzunCBZ%+mLyqUF~@n0VJ_cgHpT4M(^w|1~Fwfc|6NWbT;VLpEc|L5H>|Grq!!P*We z=|E#(p=WQOp(trR%ZKppv_oW-8dT>Qop=rXBjHC3#F|^Ex^M&;JYt9>9B@ayJH&vn zN)bw?a8y7ppAjts zB6O&3uT3u$ZyDpgF-M7EbLPF=Vf=62QkjtQQ52%WLl1ygkSHHMR3`QewVtmt;q zjMdx^Q%ywU7=pD(LtUXv5jcKoV=CsSBAKw5m1o}`Ri5n(za@U8BIB6Qm_n=2hor_? zWN4-wx=PrgN#89qCs91o?%ep9cX9Z*FHR3|IRGT6Xbpm9Y~5zCXS^K@%VZhoc(wVS zeS;e1FD3)O3Ks4}c+Q$F!!^|0Se=6354l-W{ytEXUc4)o-BvC36YPE6c0nIzz>PUq z#1L;))FN6D6OD>&2uLtBz*%Oo8*=VOhNf#JE4@U7#)3|R)_`7xZbFuT=7jzY-Gp+F zd``Be++1X%v(?S}KL`B3&YM#!lik(l@zFx~@&)^UJwi&ZHo$)#qCHhD*&Pw&_8tE| zD4hW+1P~aoB@5kA5`QlX@l4ckK$4 zeI3fj)Zk857Y+nTnJWUJ8c2exm+l|j%j4D4KUlupN{(uz*?{z$5D;XwrY%eVy z_SIh8-9AxdhYau5Zp@Z!ZEI^gBACwRAK7K0oJolJ>lWzw6X{G>IF&DB>>nCIY^ z$jEFud3MTECr;GyF8JXWylSlY_@loupn`d>cFU)mX}EeQp-%mJ2!zg55p~#MJTBfc z;d<4rf-CK;f+J%)cZoiRPDmbK0}L8jV@+D#t%%tkX1iAwX0~`B!~AD>LFz}&%NHV zh?x$YbGnEV+|#2q`vfZQBJd@tu?lQwC3~K_CQGChLWL5ub1JPkC9YC)X7F{`yOkAG zZlu`X+-GMa0Ik=YyAJox1JhZ)dwn2UIa3Rxsr9`PW|qSmqH2#Daa~jC@OKP1s{n@q zcM!?Koz6D3hvY8bht=DKxIGR&j&#yo|El@xk@o8rp(o9|u>%|@hOm#S>ofAH>rK6s z$6BR|#QbY0zpq#uv5rS_GsNn!l}`J4Grkz!Z*$ZsT6FIo0Q8@>L1AMLv;9wtdNe%& zv=ie4cSBGrqd}<#9xxRRHT;ne8WmBzp_=KFH?RFIUHdJ_N&5;N5!PV%BL%)kxpWBY zh!Fig#%Kpgn-Fe~{4oc3!CslNx1o94!G~z6hoeIuY@#DT&crZ#lx zQPyowkw0TLJVyOv4)?52H-*bWNVh8=7xFPDOLR*|fgENLqE-yynD(!GygY-q@pPy- zP5g1vIjt0W=43r^1LW{g{y`$Bg)c57Qe&LBB(q=jC`R^3uWdueplTwh!wyNenWov( zGPA5|$qxbF+DU*5=LnVy2)gVZ_4G7-X>haLyIr@$Avk=2f#DqjqllrkRiG3I~)k z`(3wwwz(@^1Bd!9!vnrX=9tz?3ClF&9T40`2xlVc(&5%}OGShD4~m9D`e7j5hPaO- zBaq{fg(fd2(M@-rox*@d$5K^mf53EECb%0~$+mt@6fj^_c|GLY^c9?Yf`W^Cx+IX2 zY_K$+H9RHqj73SSWccJb#TRt+#DpY{=c()<|m1Yi0{in8K>8|Zu?XvleD4q?r z!!!l>kp$$lDjqyOrmHwNTu-&2<*z9#x(>*f?9Fa4G?p*9ey}}gbKohUaJ|8xA}ihW z5ZJ8U+UL;uA3-F*>(saSRUYtJIJQ~10h~))PPU6*vK&H%?$^+_-IdE$f<7x*P+Hmf z-0KjV97N+f(5PqHZa9g5wA}n3?tSpv;PFlcL2}TxFnWR@MB6gh5QNc%(fJ>-^+5EH z^Z&NTsPov<3xYE&TC$8=W z?pYaK1f+EASzir{I@WXWuu0*)rkGVYdQg#PgX^X#BL3QmF4R(Qt`aBF);w{ z6Z08X0ajQ2;|s^)iW<+8CJP(&CoKo0g{s(fwS#5?4p)6dIdLMYa24 zWBEpK6^fPevT4z+P$nr}G;vfqj&Wfo=U6kD=`}Na z>@X<-W&J5&LU)Na+>)^6^kX{Ngb}Lf3>8ksRnu1Ibeb)F%5bcgE7;ooCx8>%eFME# z{3I|!Rf->d`eLg-rtqFw9Cfam^nQ1vR5CxU$dMG9ZyhkLzh`g6Tt4gEM8ku**54AL z^qyhpYF0bsG6h|YTID78Q(H_4x+!GY?BMLlMP_maH9=kkV+8(Ybb0FZ%!Z&I2aDI+ z!Y-x_*y6TgTQ2V=<6j-jC6ryX@(^|)LRZU@YL^at*_qutePl96DOW3(2}r1c-kD`C z8?iEC*qQZJt}P8o)e?&(zx(mB3$Z$~BUR*q zBIpPXIH=C33RFx>#WrI@a$i5f0;$8eCG*}Ou3qWoSM_&qJBb5M!*ZQp+{lSMgiJpf z6^CO7Ct`|I4>J+BUTxme?W#+JR3F(_eABw&9Jv9NzWv@P${(dyNeGX^+8b?W#Mw=E z+gBM5t(k?q#4G8IPg%-zO7835VHzM%?}bEC+bpo~l3x)WBy88A^bQMCuzS&bZ{Mey z!#`TfWot(w)1u%SK0oO4gw}SYD8!KJutLQDQiD)|_9TorE-uv3cKGU}aHL8VkGFG1 z*1!x~YYU$h+u^Yv%2_N_Ag)m~f#D=ZnuuW)8{1>G#uk?0qwFDK96P`n1Yx@Zre0(5 z$3B+E)k;C>>`MshMu@lb)%nwzHq6D=+u`G%FH$>#bMVpAOEZsR1{ymC5sn~o2I4z$ z-e3P(!C1$P@#LRm=MLh_7ux@*U`Ks>13Ob22UBY+B71XF8wH>ZP|v}hi2lC|H$rJs z6-yZT-H3IVL~_Nyo}xb#(mV}s3PC6oOkRRtKrqz5r-Vy7Hi2e&Z2O$#5X(C#s^ig& z+midPyZ5Ra;sfae>Lf$DI^Ilt!stAzh0*!^VLgTSh>4W}{HY zN~NpgBF8PYD7yA6#~K5?^tro8H<^hp0DiQ6$WMmS8fw$wThk42qmU3?_FPxb4zevIDZN7Dj=KDz78JthGFQPLs3aF{NM2JaGXa#i#6 z;9?POtX0QjJ% zE*{`PGi@F~V5F)#(L-`Q; zgo!!hi6YV#onzn<{^bxTnavd_6Ksi~Io7wf939~RH`&-zdwjo*3i_sy01<>1nhIrYHF?k+wz|!~L zVLtHa2K<2?N6FMtsdY;V4vN9t2a3H|$vaLH7DWGiS8egAiU>t3BruM2uy+I*3r1Kr zGH%3ysRS-4a4D4mM?5evff*?M@fIW&yiH)oJaI!_C%0iwU@G*9+cj*K|sz9`Q9))Nr*iL!9GB)2d^OJZie=iyjX;d$L-D&_&LPA2KpQ3-zFX_%wfUVp5jeMe zx$7a6F<&c7cMC??5^{j9Z0lQN+jhkMF|GWi6%b%%>MxP^S$Et>8<9wER=tT2o(=b)xX5J+i9Z5Yn@@#)U!2(MawZBJtvXnY!txEk6M5^cz=&t6bs4mCPmBZR+E1sN<-%OrHFSI^Lg?29f_4>Ihi7eDVc5XFWSZ z5j$&3Svyl>Q!70S#eWB;{|iP1${LERBFOJ59b%|*$f;mEg(Y7L&_QdYze~{Le+B7} zpl2eiAX+5h}?)B@x5idGfsJ z;Ph#t-}~cc_2SDwH-?qE6SqNF;2sMsqoS2+da31Ub0BcyXVfi3;#>4;gYc?Z#gfQ6 zo$JjHp~)k5jBvd?bh4!lOCVqtg$18cc%yXA<^9jHU*pd^D~^+brf z4s%Q^FOtbdVyi2Wl4y@V9fo>c?3XQ$u`U8UJ428NlL;qT`9_qw#I)PKNAI2|p(+XXn=}Ln}tSKuH^mMyihPAxEDW=|nau zIP3*Ox;Rdul}iTy1FRKHlfJ#5%b!Bi>6}UNl68w~3B_neKhkchMZwRP)-R~j;QOeT ztMe^ax0QvQt~4?nOwigZFaQKiMB)r`;$$mP2y+9aJd|b;wEzaOk!g;J+e zxWteDDGY@)Dj|33*~Apk!lBYp^QP4lJ}x@L5TMYBl051Ke<Gv4p~$PpQ&a<*ql|&0!SKALAr(DNR(Lon zILK7F#X(}iEo|Dtf1o;CeXNJXlOFo{a7&fF?JyH4|={MS#oDCz0{1lE}?(b z+Y`)F^UYMH@EmNMrnnqiMnHWbA6T)yIEY{U)*3Y!RHZ*$!XP z{>r$@(XDpjJ~h+VPt8Q|ckC0eur@IN7rN;K?fzRGX~-h+f0C?KE6I{(rIz{MKjL$d zOScP9s_2y{F;S=di_U7dr<_=qQx+BnR6hb_J5o*BxRL`!z2mRO6te4uV$ahVjVB&5 zM$?b7Iy=2Uhqk`<{+!$#d6wyqQ$9DsQ|A7yzcAW!@49@Irwf>b*l zpt9y{9HYHYl?W=9I(nM;s;oCZM!Sy@Zl#92bxzfqEq@50Uv#yWkmLXe9&Iemo&elZ z^~s18RDg7vR1Hu~SPiGEt>%*FEd($172^j;hoZUgm@;(fAD!07q~svZSlR&!$y^ix z+KM24V)wmWSCevV)f@7?vdmqd5x+=J&Xhn3G1c_uPEqT8HQ|c`ETs z-C+h@Q7Z35n%kSJ;RW??6jiHpt?pgH7ixip3tA+n^J$zTxpf1Qn${KA~y*m zqoIqOI!9``0-sdI@uPB080n;NreEN~N=zEba=>V|?6A@3ClDmcJfT&$gLPx|Y-r!m zBI{YGP36<-)d_{Fd`kk()hQt~ii!KWOfu`oT?3;k_DwXg=C>=1ilzY1`Z3cgj7X6U zsoqRL5 z-g_4DbSm4$pOuS4z8kV9 z3B^h}1Iw{zn%rK*n_C&ec)B-o{1RN6VNM5j%z?u^fo$z-ty(Vpk{Bt&4t?EyQWX9vU461! zwIc*kS2JmWJ@QO-9uq!ux1~vE{Cgh(71R~$LA-L>G<76%gReMP>N2f+zJ0xcz$k_o z#U4wyRI~;576t&LiPnrC@1>~A|0kSJN>?ab_RqrKhP78(vK_^r)s*$AU#S0HO@E;Y zf6*`hEUXA64eNiX7thURL-TC$wV6bR)#sUo$QA?E=)ipXW~&fEqWGekb~dEdy6K4( z-=?-3+Q)Cm@}YbJh*7U(eJKs=9MmBO6_Y89_7kiJ93QVQgF0U%mh_s>+%kL*9JD$~ z4Rh#CN)t)pJhvn?9Q(H#?GJ5qmMQ7!0`bfZ+Wqu8f8<}d#6CbXZ&)|Fx;~%&_OUPp zC@*1GsZbbaB~ds#WAZ#?T>Br8f^Uj#C8>^91p|&|GeYpkZ0;5o6)Hqs!|=+hVD7Ha>b*6!qx)7~Au12F4W~ z9o;rtLTlohLKI(NtQokH9Y@W{J&6OBwd@)SvHo;jrQxC(jukkFvwkW4?d3UjB0r@^ zY{(iJ5VzGS=BE6NixHL>su!Y{({NGWD%(8x0%xi;j~?85=3UFxFgrz;@q<0dwqFSI zLN4UhGv}0`cWZQMaRX( zQ#-apI9m;v4W9L${xQwLBY`E{h#fh*LRL^A_pCsX3Qb!}O^70cL8xxnf0_97AS+LZ zJ8eUZSp%I^`I>V9hn#>3h|wiNS3p85x?yBZPz-#j8+;mD@Cc?3d^#F;*UGAGIBy*G zc}k6z@tm^e*nqRX2T58(y*RUzHF0fAF$s>`eecR^Xy=aZIOJ4jN0ykn3j3+y&fWnS zE{%_D+Srj`Cz`MxQkg6eKbK>^UuW@Kwt>(dT}FQPEucRlR)_GcSu4_n(Cn%YkI;cZ?E0?1Y=tk4DX<*ENDAqCW3TKB+gJ+H*>1DG?SFy zPaGXYf(Pc0SYHqps5gV!cSHCb3Le-F1`4DjFO;%Jskq69F64zvU3mEb#{gPn7GzXp z7L@J3iwD&^??kE3k_Z26pp^en^8aP=5gD4=+vqtMn20!98T?~-@vk|fr;>*K>^~I3 zki3+EfS8oJ$yd-0xzpwc4!-s1U+9a#VScVwiWf#uN#-iHipRL*dZ$&1`H567GL@S37c6vp_0FpPGLdi2PnjI))e zyD@?rzCQMV8%5z{HQk}*^$n-rziWnV&XodO(+9V)p_{vGxbHpw9D8+ro9`;PWzL$L zy>n!}>+LwoxbakD**3RI5KJZy%S8JNr%gsyi?}sOm)Gc zeb|ZNNDiizB*)<-NSu+(j^=ziBc~A8@)1-*H-|8P6;{-fb!p!-D!M=N!p1rXf*=;D z`c{jq0llRW8-^H?W4ZO-yKF4FsldSxHi~jDwz--dXHvD^eD1;1yNhlS$vyUj?njo& zBs}Qg`gi$XlH$KILSD$LOr{oq#m2WyN@POj=+ebgg?Z_O_0kFs>=yl zwP*HJ>JE}rVmQr0`+P={_91=38nt5T%2%J!c^%t^duUdj?}q8~E6 z{0GdxvRd1XMLX-SU%m)@dhh>XnkoPd9SwkX|JQk!6x=Ns z$f!V&h~B6eEMxv3ETtRB^9%%1;WO&k!h|U=MwVKsdneMdze6@ivSN||T zciQ{|+I-F9Xm=5CQsQ^ENB$%*=%$+O!>dX;li)tPS)vdFpXj6hT+>mAR z5oY?JClb`^C6J#~_?tcD!rcNtk*O1@B~XE3C}E-f$A6O_$f|X(&!4C%|G8QJu~CW+ zdJe$!mi9RWq`3D6vEj&{S{ zEP`Qfz{M8uzD4U$RGG+6Pz9^eb%mrQ2;?YPkH@#;xRenPxWG~3Q0Jl z0v4L6E>y3iF54hcwyl>`ck3G`Qk%fb)*S=mJYRgLnnC0jr0DLiZWb8+;GTZHf3vx^ z-lE1lKi`+a=iw&(d&d#_SFbU(GX6)t)yYf55J4$y~Bd0BS| zl|>8^u}@O2X4owD(^WGL=ZX_s-?M)Vq><~Z#Z$gpAu^-H&!29ovd1&_dV7BYWCz0b z7^OOHOWi3XnIPerzmRF2@TW~wNHX5N4dz|*`&Uy2G05Z}^{I|mnz7>%3p8+VA>~>h zd`NScv1g?EWpMVW$RANGj^(qkBo5@u>;tiM3eWALi6H!I-KU%+ydHj#xYwe~s(IN{ z>Roxnw#dme7R03^z3GJj72#m8S;7$5t5J__OWS4Hu%lE!5JUL*x#P7cU_;tF|WQW6^SCMY%0{O+WViS`PjBr9UB!_Sdkp7 z8`V~&k3_zEeaCN>hWII&aVUhiK*H@@h4X=Mb4GTM$_EtqhPzoa5i@!Hr;hNlrHk1^ zPvhW}YIYx!Y@S}H?VJCsNlj$bV;@Z-=8rI7Iny5vk7~7sVq|o+F*PCYzvYbL_kV%T zesYGsPnkgc_Z|Fq(ftpv!~VbJftKQ`3IexkBk*b97hQ;;l;n?SeT!g=B0O@Z3;{8c z`amOn__sd=wH3BtwR6)6DYp?t9rwJqB~fQms}3tEkwJs+#WJe3bO?aqaL+2Ii)HSE zN~gOEZ|^tIY=7y%)yUOp&7H0*s@Vk=sv<|Z3ufU_=FS_bK~!5ha7P{9IcCWw&K0zA z&%C{UM7?CQOnz3_CL`#mEfMN34_vb|3tFll8_r1>xZ&5TW=S)OJrb4bl?sOfzgAe= z3d_&e%3&CTk%R&e1mp$b?zzMw3j`u{^I?N@IihW%vYQ@sHc-zV*KD z>G`QY6Z8`k6x9(j(_Apbi{iFNi1Bi0ZyWXz8Zh zshGaTgAbD! z$=UBobMJ^r$fmmKuL18V_!j-E=o?#_mRoOSSOQdMwli zZw}R_rMT>tAkuSEt4l|YCD*VSbbyl7*_jHhb!Jaa2bzvd^QEqX*z49=dyN64*{BvC zI+h5ds+-3G<0$|e(VI%L*@G_`vgx0^clZ2^{j3FspWnBOr)c7Ia=1gqiMo!?yaWLR@2R}6psvXh+c8GXId5+5gb#4Gklo+06-#N+rTYiRehNq>LR z(q@;;%H0*<`c_o($D=DcSBftkmfYx4M(m0yU+$)4mFFAzN>!uJE|-ePeP_9K74HrqXS6r&CoP;mkjc!veATqIVtR(vamXyY+7_@aaPh7fvX5fNMRZS# z-#fCbJjJgKnE&GpzY?Onl*>XfO?ZCc1JxD$B2Ze{wf#Gqp|yKFBXLcnIZ zJt7Eu^Y@+tB|aZ;hJ_|2g z7iru^2RA2YIDD`g{;>znmO6Lbj8m1Kc{I7%=VozWh<4mhKhAs{e&0Km6|G27_w-ar zlUn9+vxczY+ZMYXAa<{8m>Nb4QAD5{LDJh-feI`?J+y)fY@}RE0u4nmhot~`RFw$= z!?l*es5v(HbH;Ne-zjzmrPPu84GV>AU)7La956pzB!bf)U1Zd@Mlkh;pYmKf$nN|g z8IhV7Sv4P17UliR^Ryh9F&pE(Ni{%8z73j>@(Vt5+$tt_+=+N;WSmUJTZvRo3(imZ z?4O35xQPTpKokqu=rrqW&AQm+sg0hV4uM{+tLE#lA*dNh3sw6%E{qv|HDZUp!HTKW zkNX%*Pup4ZKvKb3J{-mW*nxJW(v1ht$Vz$U3yI(8L zh%G!eV49po+5)@-jB(YKLFOcs=LX;9m5pi94CBG{GK`IkJC{?Qj2<>W-e0l!P$&V6 zUe#JqLX+^^B@`9K6`Ve6umx3xjK~%>YQGq(cPbHdmwKwbd9F zCE#ANbZeBbK-ZZK#EfdEIYpjh0`(3W2Sx~pN*I_tKtCZh@CsJYsjFR#%XPx0vHmAd z;RW_JS@R>RFh|}kyY$}pr2BiHxL|;flDd-vY7quDZg4^p;)5NVIW&i#J>cjvU=Fai zuR4+Z?Br2}Wo(4b8HpwuqB`86l_U`e11M2&^@~lw+>5}b*{%%(R3g;Ztf=c~Y86WE z&v9yl(BNvv4HE2X;o5P`9m6X);H4nCsyFTshl|lyRmVVs{|w9vd&^c0)3r}VsU z_&bJ47i7G(`zklUlt7)tOq=H6wS2TS%=*_#J0$4UPb5@nKfH-4V(1PLd4^{V$)Ij3 zt|15m;tFW1#pBxieh2zV?n|tVZc&hk3_XY7^8(tIVM3~bosvg4(j|RnbJk(H8BOiU zE}gyYu()D%GUzUchq4@$vbm7GOG$!NMv*#wZNP9MEOOz;z{g0 zs1X zGGoI$|M+W7VSxJHr+i{x+y91r(x!%n7Qj!}_5Y@&9p$BESNRY;>ukU82+(PmAU&6) zQ^56IB_JsTU?!A)7gH|KY!z4e)3>DT1giS+#Y@av5I&*5#yj^T|9ELGR6ud{=Jdg7 zKf?L`;NpV!i@%HbVg60nhUTOKmZyDLDH4;fHi#og{Vk!_lze`avnYYTR#qPgj z=i`XbwikkMe4z5PeSm0pSG9};UIVX!97fHd47z8bc5!RW?&8LQ9`7WMY(F!jPFO#W z@{OEfnrvZEJob9BI)on&1n!*H=YW`}7se~=3hvm0HLw_ViV6=63*`pf#ee*3;}oVZ zpm7U+3*!`B%x@6Y@fTX{9NUZpKD8XY7@yk7_s{#v(iH3wi}JUmekW&Mhp;1f@b^_rl=U00p7++70mD1*|#ik_reu5qwZ~M%(1btD|6Z? ztaU@W!FsuuMeqR?D6Hh=%P}h@bm19+L)%<|5Qu`2`0~XMPa=$B+aN6x^EVG*EvTVy zQ@E;U*J1mWHW6p2DU(A?VYX6+!K{;XYGfkRMm7RLT6(Eplav@$M~aH7-y13qt_qFy z^41&!7GyM%7gt(2UI6w*i!M&kN%R@rKPu%+8=(LJR9d*f^0u{Uf!Hv?vFTOiqq;soiCYH1VP+IQw$svOX2Y zL2CslH%aVHX<};r1U(ZUN*x@B*UG|q@xVR$-ARhn96N~+>XL^RTW1-Tv67XEKy5hU z6gYW%_dv{s-KJpgX8s7Z6Q@*yrE0>Q5GIc#_cX5Pdt3~NMWBZ5EbJlIU2R$TX>jYY zIQ7xfa@910;mId@4u7I&D;zE5;!T5+rP)#Brv6a*%X$|I<;=BqXfw-)zCeHz1r+%6 z@`0U`YhIzTO=P(;hcp6#h+8q!3nF2`qj7k9Q{FIlUPc?bPoX&D%AL!17mD zE|Qe&Z{s45Y?=PgxS6rq_`3kt^(BEeIMUa(zKXcjGY)7nb@>&L+2sBV>U^eivoX_n z<^g7sVjbSUMN`l|PL`}cxyIDzbeZVyobVq4pUD27=@Wh6|D@pl+egx{mNLK^O3;oi ztk~hp@Ym=jv4n(LUO-n8MI@8mxr?=E%ZzXuNPJEt^@8mD;y*_a9o%~|c-dT>^j7_RMjbqStH|w? z=iHexQ_hO4S*PU2s%*Th>c#66O4QXSIw3<%y=yUiaZWwJO=AMh^pc(j5@JxSk6FS+ix3{`bb5eYKt&G%r+OQ7PrRSp1+TYN&g0FP0xiBx0~)qzdXSYa3)-;DO1OP2>wDN#5WNfE|QJOy-6CsMUE5@mLDP3 z5CBa+XKx5@%_zkwS~>d^8Ps<2y(iHYGZ4aB_lj9kv538#=HlRzrjV-5&omHf`AR19 zHz|#3?3R%!mv~)r9i5MBU_Qq+*&i9t*Rh%9{XxTg#A4STMJzzb%r#E|^>S+`r&@h< zj~4i>71IL?mc}a40yZx=+62iPmg*IYs;2$Y8R?;(g^F)y#}$J+nHltq?>5gS%Gd@Zu9CS7rGePsWq^6p{M zw0G?A^+d=dB8;!?5S&{)qA*uH`&znnjn!tJD~}={X-pGb?r`vZY&)Z<)5(_3KThoc3aa@Maut3+rT2hTc)FGgB{`F1rRK>)LfoJWWa5 zYR?s@c$}oOCvrVTxzQ91j`QH`p7Bopiz>8!f=nU^=o_o4%~3HE)bJ~b=h4qvi`Yg! zNWXgvSKzOZq~@zI+#ep|=BZA9NT8nwe;I;#WA~D(g!XUJ*Ij?VIQn9M94!K>K_m;S zhg@lHR`P3g_k%(}R-bd=z;rVw$;h~vZ&P|rGbXz=$btO{TC!6_8|gvx{iqm{_ycJG zzUs2y+Jy_O;F7rlwL9<^#_4g~4q~Y-1k}>>JD99#71H%r;RLwo>KbfrV0pUf#p>f1 z_&WvmFSGTPI9>5-NzK$(Ientv@gzM6tc)2Ba)D$3EK~#OHs*2>zpB|1Bu? znI-$DB1lkKRYX!n|Hv2zCJ_&PFZK_HtTltw{jMaGUB6L0Z9~owqA!Q(O5h8iE_}BTrc`wd+LG923mUu%QbFhVK8kBF0+C+i2EpRk zq-!2{l+QFDW0GjIQ)yu# z1k~)PH*c21r>5Mya|oK%Dk32}ycC-+*51APq}MA-qYRyIA>N-hSsq%wM~A)lO^M4e zkQQS*B6WY6xEavYhm^%C+?t>(XPJ20Qh9fl7j*G}*Ji4D?P!#C#Qgr4^#oTTb{J3B zg&=l`f!PDZz|TiFN*#}U?yJo*buwLOq=3Oo_F^U^=%t$?;^y&Tj#4#jeXLxtK94K? zS$_6Z9#tS5&M3tkA>r6~mz$HIJWnTHnKV~>UJ9&GAT#C4hYCDG5-^*YP_K_;u+lxw zHyIT2kWwvGwsCM5RegY+Nk|-G<~?kA0?WEq?hqF=N#*PuF|-rEaBj~bN;3huF~_dOZ} z)^+wLxdfSvQ5vp8jj+=1Q_S)z{1#g$Ot1oeuBFa4z8S=nn;zev)$X&sqJ^b5l{(31 zF*lc&iL6b-%t!p5)XMOJzx;Z3#a)3Nf`Q=%DZm5;lKa|Ss2T~xJzwSPAs~i>juwQWec;J=h;B|2xEkCZ)1H|(BzqRdlqmBpFm6` zfPi@OjwalCf}T^MP2#Dv2dT^(Y2GZPx`llH=j%DX=pzAt<`(x%O7=Oh#QVD`S+gt$ zb-s7P?S8RF{3L$`-izn{5|^HD(3LjC>oLGx`2v6)e~^7I0nbz9R7cpryRP=q-MVtX zy@_w(TGw?*zw1rEsq+O0k1CJfoRamsg^blA=e0{imObTSbI@pS>s`t_PrHs&NoId{ zOxK8rk)A#!Ce9g{yjvx3MWtu~zlb=b-~Xc1pXX^t9Y$L%Lxx-B$NudvEQr)zb}hJl8~_{J#g8LCN1JZ`rMP=`6UQGMWUnxP?FEDDDn3!4#LX1(e;Mfg zdm8AUji5kPLlsFC`F#Q*Ry>#vgLxo(0a}>G>_RLZPy8&-$*~^$zkBzu*D^uM;`trf5ySMR$C)~&T!{X+b z*vCLUYnsmJp_7#K1m>|mCB4X^n}J&3?${Uf-WVTp$-3Lwov@YA#38eNf$ih9+|0&j z2v%8oGOPoTOL{OXdu~b4K*o%n%7Xrcg1!vj6x2kQ?bw%v#Z6}IdHf!4x*sH^u1fG8 ze-7($q`fB{Fbch^6Z^kb;RK(Mw#JOOLHut>KoBP@ zsjsn-L>JYP-o>B~!u?hQ-76#{h_90y_S}Ug&(EbeGc6Nc*vqLyL7ikB`4}z-hTBn*Ks@sNqFh=-wBlkTxCz66 z;g@!j-h@K|Iza|aCS{+ddj%@reAzhAG53`q5*`or)>2gh9B3d| zROecU&$P`Q=<#N7f&y!O?WgW_$CZ!#`m456CCmn-8K;NZgY4LORfBO6?3<}Pbzn+P zKDoP-a8teO_>2isenA3wYdYOkHlcKCT@XSPIJihz{u2*LM0}{IfPxfsLMXmCBK*(V z!F$=9tTux)cO=-inopySG$|BXQlfpvhA)!mW15y6wioKBGAFXmYc!@}Ua8Jq|5Ay9 z{9Q>br5k9wmnzGO`EHF2F>+G>nwX+>iOUcvV*z;y#gcFe#%CFgcM3pwSu zEd=M0s`|FTsEFb~YkY78qN?vzDQ-C1aV>`v|MC%fQI#RLtH3gfY$@k_upfG&b6G96 zvS;nua@(Cl7;J0NBJV;@Un3gi&-LHkX0HVmfe9+KsCrFST^kRzOL1pH`WEKj4ayh2 zcblrANmW4(gN|6>g7xJ;TPmfZ5lDT- zV^J#_;!&-YaYm;q$k~8Y$*Iaiak&>BR+&Ph&3ea30R`;bywn_pm&8d6wdB;+!8M%A z96#x(4aD)7RRLC0b5rU~1uAV<=KU>>W}LwR4*Q{vr@n!r(u$K=lV<5*J3p8bD0gR{ zi3L4YPXr2CQu1yTb2mtur4XoF=a!w) z^t}e-^lnh~5Rv7rfH1mb=gy1dd0VA!1sNv0SGoiWArLxbMR-LT`8zd>M)3CY`*@mg zm*TZTFl+L|TJyd2%x_B8igkXmhd{IiwHcRg?(TD+>ouH!Dj9`48dAZ3in&3LQVk!R z-pVb=28!ls1|9LEn79sT5< zdTAHH;*qpnJ7xnnR46r89SWhC-qPJ(59R@@#l%O~W1hrePd=WkDMXg?*1CobyCWKv4k|2vC^fu%tHixTgPkD_wn#8VY!~0Hb!)gr z!U@8N-2z;Z!WF;LZ1#PPT*t<<-DNjocYvGFTkeG%6qfOx$?ZbiYaXNTx6Lr{HwYl? zw+%Fi-oYIlZ1f7>VG`vP#S(=WeEWOMZO4J7v;K1sG4W|xlmEYT4UvvL&;n@S@ITXt z6T4NR|N0xukcw6=rpfoF_*e={{ieGBPA5=&BB6?%M2_*UU5L8IfV|I?oBA^2>nPmTGH*W7I<3$B6Ita?73?1i} zYy^FHnYs(Ot8m~d!chm}gl`Q#tL@t?(Y6vefONO(XFO=&XlHc1Sp8U$&_Qh)_-%Lg ze;PXrpg6MbZ<7#QgS&fhcY?dSySux4u;4Di-Q8V+d+^{Ibbtu*&3>=2nca8m-&Iou z1^t|!JJYxCJ@Pw%UsB(es;}{`XUgPk;ZU2z`w=Rv7u7Yw*2jpCBUp5}_-KLEV}YynDen-6i@{jD_Jjo}f<45{sGVV~E;A z#vz?NqF~VpTIXn(bjowTG=xm8wPsMrY#D0s3tLwlbc;1x(+k#N5PLyZVL$TIoHJMB z);a_mUWp`ft6|IZkY^hlX`sf#!#{{`l zP#~9Xc=&ykH8u`@asi$Wouv^XM&sr?RY~#}C09_a2tC46hbPqzIKh77Fm9gXQTubA#72mH6+mGEKkf2_Ds|p8y z+wMDpA*|l+@@Pb`{EY7y7EKp|YU80J75=47cJ|IESZVsx8xLPrysBAHR-eqK%Z#v;;(f0@{dYGj25Fqsb5Jw>H z1Op2@nh=eC1s9Jin%OvP8F3M#J6MZ5%T4gMY3%1n;o}&;5qtTp{y;WNi`lNSPjSkv zFJt6%T^*9{4qT*cBwZu}`2l$|xzaO8wn`T&!fPbpt)CBcYf${-t#5B<sqFzs5LlW}p|+?k8P#mxAw zhqW_DXv*6H4+HrY=(hKs;)}cRJ8Ts2_x**K7l0lncx<<)TR{x`@P0^D9Hdu2_O}#a zCHJpjXV3Lv|vP~^`s3oLb8&8Ne~7?3$oNTqC|8wW&*^Q{!nht)kx z>q-+r4g`d83QxyV9v_*_tgTyW%IC0TjaQmJU!T88kig~)B`A;okyw0&6U@}!Wf@5> zo`Ha~0K=->#KO5I8Zkv}qw9EdWG=CAm#K=55vCv}D8Jij;P9i6jmXMq zuFSoc3Gfuto>to&{OhQa&c;8-kjxq7%qCgEL~6zAHEF96QBcL;u;3Eu_@V2$GffTK z8u6-Du}H#%)<_5nF{Bupo@LwKZAGPz#!*B?20J zwV$7^c3oLz6cv+o4I-wUh==5qn|Xzh!Xz&91C3$km}D6gXqhImAJ%u%i(#g|A_`yE z-gpg^oQ&7YVI`PqYs6_!X6#pAX9(xm37NAKTww-oXs1xPm#;S^<8yY<+=YGh8!vLs zCkxr9=UKm@&G2>-O^XHC)Cr2%f9C$CBigJp?`2Ri5m-XEy(U{><;|X~g(if8dm@@v zC>n(<0J#4sRW_+?EJ0U3NqRx6Ilzj;hDXZ5e+3iCaO7Ju9L|-XIQJfc??hcBS)~(u zu*8{g2KZNWaN@J=1xg(s2)+l%?U}Wy)rbJRKc4ywGdUXQzT4)$mXfIs_DmBp#BUGBC9v~q-8Y;Ws0~kp+U59t zXULDjCM5&<`xJVjkL2p=2X*{H_55>o$4j^9OVu`$YV~ZE(ixY~o}6UTEjD9I zrWd^0K}OAyyi zJc$&c!H8*zEkTh~B^kDGzxSX?QVQ+#X}iev8|#` zOy%G>bG~7ut}c&BYQ9YoNgIukyh#-w6aVewuse$uGbUv;S@7!uSXtR34qDqRYw>vk zu{$wi<#__AF%?BYigGiRB3XHhZ4(z7blE{R#>yzJ><`0Q;RRadcO7OJyE&u^YCt7*GE z%4=$zlCv(2kEe#sNkQ6*rl4(HN4*W6}tVJ+tc?tdQ}Te83l8Wz6H@}P;No2{i#L@F(bB}Xk0D^c0zWt_)2X__}%g`E`5 zUsa((=Wn*ey}!vrb*cy~uroAg);CwFOn-`Pkcp zSs{z6o`@b*1p`gQmSl*N9Hc13laf~iTRgonY9BHeWA@Xwf0)+dEj+LeD@tyBEiiSC zlacrmH88Sp8L9ip#6l_bTR7_g6IPWqZG1ul%kH{h?1wb`0>^Xs4(xAK680M$2wN@` z48`*W=I-Y=KYl2Wjr)@XGviQaJd7Qwi8eK+6Oky5zS0C^8Ocz(4*lL3`N_mPiE z%FFe$6N54qFmH1!@zqtMvAVwS#lj-XmA&nPqh^2Lb;*b#u395xqHum>v@Xi*M^i#S z0Ibv2k$S`^sb&+Rp;#s-?a&FS=4AGF522Imhaz%ImU!s$#~{*_$x+Dl3!dPFOR>WX z2w&{GY>tewm6)LTf~B<=j7sq#a*tPx>AG+f=U*j8qQ38L7P+IA{APX}oQzh0#I&k? zq{0QT%imM-HPf4LeB$p9?ZI4RK#O!3lV%K0IWYz}>s;qDWb)2?iwxX2(*I&$ygsQC zVI@$Mzu>K>%UGD>6J=i*B(bL_1B4{z%g5hx@mJ1wr0KvLb&COL+vY9I;c!!FR5$WkKb>hxR zq<2q6IL*mCOyBvwGrI^}mphf3(`{4d)v={O=Z?P9eqF(=$jUGe9<7$NLOW}p$0E{u zKp*H%Fk367cYG>KA#5IPJOtr2Bd4FgMXO@{3MaDrEbj(wrwp*2uc8G8?_5p0Xn%MD zfupIZMqK(Woa7{a#)Wns`BSnT?Mk1+{)&(h?R)_ucV6n0^ifIu9RVl4TFjtR=&ZHz z31T4Wm(+;xl0-?s6Y0EH7@hKH~#?0O7Kytk0*?zszLnb>+stf+B0&t8sHIb}hl zMaIS$dj_ypZfH^<@aX4uWW7@hvl--p5d<)w%{iNOtvHq9^R? zB(BG!$Pd=~@ILDmJw7mbudr(8Pyy}gMkunMwqn^g<32^oKR9TPJ%8dusU`&T?7@xN zaVp{*=CmiAzxvqNjL#||?6XCfbWsl8U$fw(ut6u5PG7k<)bh4F;p~{YNa{X-KMe`j z)N4gh@MNdb4^EAyPCj42bw(QCig;~{_X9T3Rm+6HZePR}leSY4@kAWCsmN#RMp@mu zudjHJOB}}1d}n*pXR_yWX-#GK!MtC$NR8vJPmK+9lRH!M-E;EwICC1mKl+-DW@NC~ z@N&GtQ`qdV5}U6c1&_49b;$NoPKWrD;io;1Z?0dj#iXndn=@|s?$h%%eUr-Q2{D&- z32rnNgjC**g)%Ue>4WEtt+85+M=Tl8ElNqrZN3b(77xsN@Hd7aED9aBfPsuSs6uHSsj7IHLI9Su(fMi|X*!2<@zhT{kc zowEftRP%`fCvS{cygHZTL50+og|OL^-J!W{HR6+!p^niA$$drT7nnn`CG}fKX)C| zEtoV%FF!HbPwl*5>=!<%^S<5>?gYg5N%7FT!TYCBC3Sg(4OD zzMs`|PDUi!pRV^b#Dn~4h!0n_AI;KuiPCIJuP1dyR$AsRyw;inyMSCvPQSjHm)|f#s}jopdPZ?;MdjAmT_0qtE!Q%e%Hs&mgPU5?wa0-+q(_nS zJiU7hKJ&VlARu$@YcQXt1@Z=bK3A5FWDl4PoO$p2FK`T7RvxZ|Ige|&0QcWYSb1!wY!&byxvdj857)hjDbkAiDp)7w$q=^I|j(s%{L4{ zi^h>+s~z6%K-0sEEu+71Dr9ixrD+vAqvM{Orjx>fA^O7U^{LZZZ}$5ME+g8BzCTH5 zjuEdHD}j`(GgnHi?1=KLq$`ylbGhg&-2K5*rOmOlh)OIw1PYG#b{yGOFySn5o_o;mt_EG0 zEA673e42hr_i!`$x_ot!im~1a1Lmo0DwBaV#lsxF5kpyFOehP&Iu#!=B=IYnl?S)T z2i)7*fXU+;*bgahKWmaVG*jA74l5!;(d$p>GVsnP#dsgSaiLXtqO~0U zKreWEBq!bqQK18cn0sCQ&V|Q4b34k@!xnN=lv_tk9dQ++M()&#axJiA5FUlCG4(F_ ztEVCHw661Yr$F2xju!_Vc#}{_I04Q3)+Q|C;c!E(>`dwq7)+C7`ACPrz#Y6r58t?% zE9?)t`GXMUmp`!}H3fjhaAm498gH`Lal$@{eciaoPi769_J|EqGev9s(4Uv0HY3s< z#3`M~EnTeq{-k%^89N07(sML}$A*4fH6sjDihe9&7L4!;D_NI0ZWh)#d_&bSa_W1x zNCL!_CBL{+_P5t70v5)YX8Me`v@F>rR zx|pNwsSs7m7L$IdL#DU7#c`0fKDpy=$>#%N+ux_>uk%dKkzquDD-Qf*dN}75hFJy- zvT&$UCk*Zc(KCO+e5`!>AwZ==a0YS^N|m8@8o;;S&ah!LSW_VdBD?74Ludy8!e_|q zid~uy;&f*V2i$C)R)f0Bj-2l;e!#xX_;cwbSzUZyhD2aI$2B04;b{?ZvH%$dXF-d4ifJor3Y6KYjxj@GHA5VxlC z9?OBNnzV8)P@_qZV-Y|F_=yEOOg=&W(S?CDwq{0dB4&1%HMVVIX4=mSNmK$gr2|~c zo`aUsSe@-LgNkZ0Y@e*s7Coh*pCY*5sS_FrdR#N0M(j)AAq1N-6dMD>gO%Y9ue?5M zc^`);rQpJavLC*eIQA3W!ueZg`3AjKI$pQXRrQ;<>K%)w*`LAzN*DE$Gnn*!o(vOy zne|pxB=ZPbMrcPAp9;JzM#U&U4O)M_Mk6_4-Sc{_=P7n%h@1fVHB(O{3;`KE0$xwV z*Fw-^rAf%F>ntOnKsQPE-1`wb{Z~99z0kpHO~OFu(|Y~@1Rv=Zc~6=9y79wN&zS0S z^=jSX5;Kbf%bIoO z72jkC9?Ff5=#~nW6r3L7bam(QjBM!_`PZRk?mCa6BAHaibz#>daBDJoky2_uxgOhi zrff2o|9G6q4r=4@{oE56gR#HWHj{cR;9-B8)q?HY)&k+^6Z#bHzEj%5ap%pzg3$<( z4XvGuki1b2bz<&48Uj8y3*;{^dRlif^p-FcDM$Q$-*zMZG|kh ziqS)oCHjx`nBL`e=C9U~x>H;mynoc?#aW36`RTDfQTvg6Jc2}x^e&rWbQjMjoaxgn z^fD#r?KC+h?`ntEtJI)IQoFz5Q-Rvj3cFZ@esuF;2Q%m4Z;3dBmb*dD|6!*kBzPdwC$|l_LQ$(we9t|Y zK20oUPbxnR(izSwCf)-^fM{Iehg;N7ON=b}-G_QWaW38{8JY}i(y|Ux;{sP!Avc(jbeYI6f7(Cm;oHC4ivbmYvcfzvS^@ND1?Gye0ykIlx z=aBR?s4xxZj}yW+Ro`1;*^0L^!qQTIFq%w4MOs#dRtZR~v7XFmREVMD+*kvmhgVs> z6pdfSG+6^mhcT?rFk&{BzCgyf0wWSQrwd;wV(7MO04ZX)Yzb`T3!{Bk3O!;imJ1cL zd1AsiF&3=lv#(-;a9k~2!o@C@EYii~>NY65@2wi;3&dHyBnwj6Fcz?g^cOW+hV2$L z;>7GNY#PME*_uTQsAKwZLRleEa+i;?#1e6`myce>&|BG@B7r1TAE=@!-T?y|3&2?B zUnM*Kl%x3)%lvOtLv>3TR}~o5IH$;@ZPcu#AzFf_Mj%EG`XGUffF@%jQ76bf8tj}{ zdE9?sXYL;Sb9iefs%FX3b=47XIGi64SD(`XLkf{(lHh&Lb-#Xd%5!+Izcu~*rZxa) zfNt4(pZ9bI0Z)6+D!YB_@=QGN+lPgg?tP6IqGb7fSH`d|Df!c{rNF`s1J59yHdy=R z%ua{9vyNB`2{=Eyh8As&l`;t4T39}(^2esy;C`z>!?PaQ(-|zgryfsy#jsMk*mdD3 zLuy^Fuj0Ii4zr&(DhL%&LZcFnqe#Nm9(6bHMc~6;L4d=9nHd63M9_fVAL{Wx;|jsbMP4xIbXj~x_{1Lbjdb@h99^zjX^cXM+@qQ^+@*f<^_f=hXB z*e1k?3}L7>%~xx0#%8_FpsnBJT;GZFdTH`;uXFeGvjSMv8X(Uk@lEfTvR#>7(W z^z<`(tOxXg9tj=AVm2wXQbUPu;X?A;=KwMHz@`b*=!vtgUX^6GU3td3O1je%BN5%` zSgVF<2kky58x_Ks$LLY;rTMY2Yx6X=^w1pvA>ai_ThiuXYaE>RHWN8}JFCm*FhS9m(1J5bt{Z%%C zq7#Eyjm^w}d$>g35Aia4?mhs{g{XX~Q^sQ|VgVs}>D0{vRZ_c#Ly;jjiJo3{k;hdv z$2HDnkKv74;}{t_&-OJ|vxFaeo?YFWsHDnF*NH!D{46cg(-6S##~efFv8RcJ`S^rQ-@m<41k@cEZ;ap*Y7#nkqv3$YLxVzKj#z-xM=CJ2 zPvkGbn?Fq-|0lW7f9L+G*#1!#>Ua0x*2Z~DzE3V`iGzZPcVjg2Ui*X}7~zP6vVzhIeGsaDyCpLGJTu6GWF0TIC=E*WjMcaXQE&wn7Xd)rS zHq#NbtzuW!Jpk1?vtO?4@n!vO)kmj6CuwcV)=OSaEORUYB@tLucstvIy0&ITP4hI5 zihfq+%aKbM3To-^G8s1UQsr?cBtLvU07deTSqIt_N71I3CvMcFu@b*UTz2to8B-mK zQR;AS$_z~yEDf;o8@E0>4z%n(XRX$fl(AI6C(oK@93%Cejzxjxsvi z45M|MT)NIj7!cKmBZm#GAHCE3P~IAm6%MCi6tPCFT*H571Ai!wvxZ?hzPQP{T^{}h z7Cte2ksq1Z{^p}HQ3*%H>}wWjW-@ltw<&{IU+BwL$Kg2;?|d%|ww!$^jpkzyTVwre z1y0dcf=&}w#(0Ll2(6p?J>=Y9Fi4KbY8;|e4Z^Bx@m(Buf0_*n4;1x7>nWQH4|N%MA?V$)XW;Qh^=;zO`?{>_D}YR z{N#{%v=WbW%^#EKiIDuoiZJW`Hm=e9x@UJHm-C+EAZJhCa2sqTb+m^l3iZcoDtv3? zd}}DpW6_q!HVXY(hai3jbF@dwM+}~v9spEmOqbA==o`te-ucQVSy6k(&}KjQ@*Q{F z6{1jvFNFu+waRx&1*VR5G{$HkEazWv+u=#+FJ7{B*XkF?}Xd`>R9^zr-oDx&v?OTE`)-52KP0hY=*Mv z+X(43l`@(|MS89kLm25GT~7|d^#^UQ9S%SMZ7fh|Z!k%kN}?j&)Bf=E+vetD7v#*a zcfVIwbVelJh*^GqvHgz7&v^wTRP@hqR(`nQoJ`B3@}(J1zD}0;HURy zeE2=2-gb&shrI5K+!NjWps?Ym)jUsWV{@OAnZ1QMQ8pJcc`i-j5t#3nru?+!RK*{ zr8J*aRLXa~+sZHt^wnl{n+SQ$pSGt5adf=D4nEUQt~J3l9S)+@ygpPUI@OSPU}O&= z*N@5Xl|hwrfAq+J-^XM#RCs2KiRMoAEmGx-7gt1cHbxg;J?NvFN)G%kZgUxKoE;x9 zU_?j)huh{KE_Zje5v)9Hr*vE zOV)Fcwb@1NuXb?Ys~_MhAA`!XlY$wD3YOMsIX#W zV&xQnivVBKY-rHl#qAjq_f^~_zv@FKd{i7}kyeZlvS|j2CH?@)C9(_n`8`7B1$o1d z@cB3GMY%FK6w$Etr`Y|v7A4|Sr7G#g%31o=+@#k+rb^^9w4s#k4ooN8u=K_#Z@!u@ z3VkYLZdix*AS!>f#eEbR%j4EI9lemv7RuUQcmhz9VJ9fvcDnWfRSEQW`ShIHwCMP3 zsv_MK9<^DnT0U$)Jh!noNDMR^8U`2|h%-~;8AvRr;EX#<-`ry(o_%JG!pnk3H2P>* z+uA{fDjO!W0Wgq#LzgT487T7=&-B@q+C_KM2?8P=K1W(NtvF@z{tSD=^^G_S+BQ>I zJf9(TG^0t9$I6}^l?%p*td?h%K}4oDmfX3o_$QgZLEe@DMnH|xa71c;c6`z=+S}Sz z`BsWYk2*6Np4bL<6i#ur)#^>~x0i@Wobg&4deLGj>Ez-@?@$a=u)wt`RZC=8YTFFg z04;dq&2up3&ZPFT4Rsz~&I5HcmXs-RNDZPD2`)RH*0WlTJw0;6FsZCh=vF82>N#GE zw?eX?f{EWYMH+UtK9{CeXmjZpkA9oDob!5*J>`wRGrCxWB5l}0{$M(&O*XOTfA( zHbc5ZF)NbOw*4OdlR3$jaI38MFv+~3&Ev4*5tK(XSeToE(+O%AC91x|lkXcz19l^$ zcxAlVxuIUvXw+?11133?fzz$x-e;~bW<+~nS$uguu3?o zY}FPzi&%?T^AgrydO*^(5QmLA+JSe_N8s*8@SpCWKb9wd?T2Dx>H^XZB}PTl8CMi2 zkmg!r>WvS5C904WDp4P46G}=n^c4#i4s8e>`{rwYX*{drlHO#yn_5`skCw|pQUx+J zUWIDj>jEmTrHboh+OmX$qutY-^P2nIKg!eIqi4diO>b3wH`;>J5m#Fcjy?G~CHU3j zy8FJswgP$(70fVXKkvgf!Jcys ze@)NgqixeY*LwG}y!S@5@B+F=ZFOp35L%cIGRG-0N@2B_HJCEC9`ktdiqSLLkQWLZ zL%0vohkns``a(H=8Vm8)I3p@5d)zw@Vd1W^kF@M^det1~7A9zBs6H(GlvA6~j;Hz0oaI5Z?q^w{wB(R`-nKi4KRU|mUNv0VScoj z{$YOUYPPX%^q_uamvo#8H!8BtBt>(~YDr3#l5$0?KBIYVept+Dz~@StJAG1P1yi8J znOh=RLh*1eQU2HF`&pu3V)SH(Ya?rk_FDka*)A6(qYlpy6`n)xXF>g}ev)l;qlylb z`FA7JQuY^63mK|4;-$N?G#=J7C3kj~ua94Q>z>;=Z_VCIq*?QHBtU`ZPTds*!e@0` zj0%f$6>j30H8_2i2=ft^+OBEWp%z@eVq0$h-xh?RHQxx%!Stylqq6bQEQD9>2_JP@QR_P@IylZUv@hTi z+&XED8^?tvOGEiwnwvelycKmPcGhE^__RhyA6I`qhehJn<~SCB&GRh%pkXk;0Pu zCSLk=m4p6>@G4>aKGfdrHX;gw584y|@W%GHL`Z&7S%zDD#!zpK&A_tD^!`KGcd+;9 zYUtex8VYGjX^I-ZkbX}(6PH1NK?Ocm&4FzeN&hl2{H9b&_HW^ZD%A~OZ7Q%6(qw`z zyWqfw>utrTD$%QLx^#4y!6nKdV6mPuW+9fKcV zM4UbyIZlsK++0A`U!IjQ%_eP)Qn*>m{Dh3yx^~!N=Hq6D|3+B-I(>VgkN6!l^BY;~ z4LugDsiEr%A7J=oy}bB5NiC*672h*;ag2tzj43O|HGq_-9xLoaEhG#d{3m>QWrsAu zN%RkJiJ@hdVL7@Nhy|El93-6m1}@jv0s;K>5;a$*6=CX5t&(?brYkO!_o}sBLIrB1 zI*5+XQ=P^&ndk$Gvpu*K-{ALYr-@n2R#X{BXY}=$K3m_cZ@UJwI@!&W4g6rIUg@KT zWl<#@+vWUZ2u(?yyZPZ*Y#!d%Hb>?td)R@)@QEP!samH`GpO6jgP%slpf0{kErKC{ zRQh5!D~xK2MTd`b>M=?(saFD;cn~|bu}{B`YH0pMDJ)Q9jTYt&JrQ~Sr*L=i@lLgO zSJyZL4&l{B(8CUjU`~>QbNE3)j*w#jtk~w;AqNsGF;7-amd?FRYLrRFT0C;Y3N$Q} zc!aQdTPI*CZL@Kn5ILuz68O47D~fgMsvHA)DD8;T>x#_p62h3pSYFyK2k>#~ts`S` zD*8w>GbTo#)YDgESs*ar2yizm5Z>Fw@9Tis~$zGsrK}V+W`4P@600YW#HrI%+&&j$tgTMYo?a9 zruQLl>P3^z+JAkqoyx4N5rU#AaNB3w$I2QkWM4FF)_p*N!PKpjLD=i)sTE_RlV3r< z_T{wK68ogcfIan^0qA%ZmS?D$yegS&$Z08y!e}%8;GDRk%UXePk3;fs)D^a$?n-K2 zm02yhV=F@OoE;0lon?wWr4rLoEjThTuhwSP!P;EpD2wf_SD1>)M@mA3-yup+u5uj?*o=ZY1xW&A$qvN` z9|9{vdt33aE~NC3AcxV33E;cM!q`(Ky(&GwAyiAakk>iyl!jBC6LsfbJ+swy_i=U)@vSoKaB+??yk&PBbUw_T8s<%GcQ=MS~|+5=e!G;yw6W$)qJEV=foSW3bCd&KUM3i z9S3WU0`f4Hfi+mDSS7lgWoC1hg7kOCr`UA-RfeHDy!i&jFj48IZ=Ak=d7dO$=Jrh( zu-zMCZh=3@58xT++%wu7(#5>jLDL0vXgD}-4dq9XW9GJdeBH^2n${@=^#zsma1-4F z=TaHq*7J`Rw10Gr{ckr|!T+!J{4WR3i+HKzKmm18se})Bpw8QHy^l2L#DRlFl$31A znG5kKEr&jcTfMJD@qSPZQWS9jGs(=|+?KZl2y?-VbdbYD+oGkC(b<6zW`L}Ptsx#OLdybRXl(+|WNu)eK|kWIOsDOzF=YIf6d0+(v&)M;GfTdl4W zDay7CEM<)qt#_%IU%@&^9X7X-Nn`JMA3oObgtLS$^AUFO!4-4%GnH_r2h(KbPZz-& zh6;u@=>Pr%T8e%)<^hh95b*Vf!rgzn`u=Cg=>OG6SKrp&XXWME)m27HAW`O$;Z z=RD-QbmcFcJtSmJ@DdYjO(Z2{EnE-gt6ZQEPvtEn&Fw9a5GC@pk`akKG#LKZ@y8h%z#o76f`Oh2+HUwiCl>~efS&qW^I=d9XwTUf4lHnE@!KVU zh|GeLKwC1tkl3LAj`TMhlD}!t2xwP^ zK+&N3_b=#jprG^b=-<|uplbM_U{DS37q9>k2pB}s8bkx`S*ST6b&l-_kz9zwom!(t^ZX75EKTgHunM>2KJKt4fZePtiL)x{yf+G zk4pws<^|6E}RI*y=4RWHmfDNyE1i4`aXG&$gfqH6$3`O~NI z@`nG{oDLc`d;w>f`~&#^^c?y7K?aS9yjWzi0JZpc5fad$0rkzl@P1f=^8P$D|892z ziUW02zTkLl{{i=Zbz6d(19gAAm{V~CHTTOam9P$1~q{{>hIT-*IN-~4$e0Ez)! k6u)4W-2b%3{QD)cf;0p$3jIgOiv;WlxZIWV_~W<#2iw@@<^TWy diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index b8b0a9e92e..47d5f23ab4 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -2,156 +2,57 @@ package com.tangem.tap import androidx.hilt.work.HiltWorkerFactory import com.tangem.TangemSdkLogger -import com.tangem.blockchainsdk.BlockchainSDKFactory -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.common.routing.AppRouter import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.filter.OneTimeEventFilter import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor -import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.core.decompose.di.GlobalUiMessageSender -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.core.navigation.share.ShareManager -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.data.card.TransactionSignerFactory import com.tangem.datasource.api.common.config.managers.ApiConfigsManager -import com.tangem.datasource.connection.NetworkConnectionManager import com.tangem.datasource.local.config.environment.EnvironmentConfig -import com.tangem.datasource.local.config.issuers.IssuersConfigStorage import com.tangem.datasource.local.logs.AppLogsStore -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.GetAppThemeModeUseCase -import com.tangem.domain.apptheme.repository.AppThemeModeRepository -import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository -import com.tangem.domain.card.ScanCardProcessor -import com.tangem.domain.card.ScanFailsRequester -import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.feedback.GetWalletMetaInfoUseCase -import com.tangem.domain.feedback.SendFeedbackEmailUseCase -import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.repository.OnboardingRepository -import com.tangem.domain.settings.repositories.SettingsRepository import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles -import com.tangem.hot.sdk.TangemHotSdk import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient import com.tangem.tap.common.log.TangemAppLoggerInitializer -import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles -import com.tangem.tap.proxy.AppStateHolder import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @EntryPoint @InstallIn(SingletonComponent::class) -@Suppress("TooManyFunctions") interface ApplicationEntryPoint { - fun getAppStateHolder(): AppStateHolder - - fun getIssuersConfigStorage(): IssuersConfigStorage - fun getEnvironmentConfig(): EnvironmentConfig fun getFeatureTogglesManager(): FeatureTogglesManager fun getExcludedBlockchainsManager(): ExcludedBlockchainsManager - fun getNetworkConnectionManager(): NetworkConnectionManager - - fun getCardScanningFeatureToggles(): CardScanningFeatureToggles - - fun getScanCardProcessor(): ScanCardProcessor - - fun getAppCurrencyRepository(): AppCurrencyRepository - - fun getWalletManagersFacade(): WalletManagersFacade - - fun getAppThemeModeRepository(): AppThemeModeRepository - - fun getBalanceHidingRepository(): BalanceHidingRepository - - fun getAppPreferencesStore(): AppPreferencesStore - fun getGetAppThemeModeUseCase(): GetAppThemeModeUseCase fun getWalletsRepository(): WalletsRepository fun getOneTimeEventFilter(): OneTimeEventFilter - fun getWasTwinsOnboardingShownUseCase(): WasTwinsOnboardingShownUseCase - - fun getSaveTwinsOnboardingShownUseCase(): SaveTwinsOnboardingShownUseCase - - fun getCardRepository(): CardRepository - fun getTangemSdkLogger(): TangemSdkLogger - fun getSettingsRepository(): SettingsRepository - - fun getBlockchainSDKFactory(): BlockchainSDKFactory - - fun getSendFeedbackEmailUseCase(): SendFeedbackEmailUseCase - - fun getWalletMetaInfoUseCase(): GetWalletMetaInfoUseCase - - fun getUrlOpener(): UrlOpener - - fun getShareManager(): ShareManager - - fun getAppRouter(): AppRouter - fun getTangemAppLogger(): TangemAppLoggerInitializer - fun getTransactionSignerFactory(): TransactionSignerFactory - - fun getOnboardingV2FeatureToggles(): OnboardingV2FeatureToggles - - fun getOnboardingRepository(): OnboardingRepository - - fun getExcludedBlockchains(): ExcludedBlockchains - fun getAppLogsStore(): AppLogsStore - fun getClipboardManager(): ClipboardManager - - fun getSettingsManager(): SettingsManager - fun getBlockchainExceptionHandler(): BlockchainExceptionHandler - @GlobalUiMessageSender - fun getUiMessageSender(): UiMessageSender - fun getWorkerFactory(): HiltWorkerFactory - fun getColdUserWalletBuilderFactory(): ColdUserWalletBuilder.Factory - fun getApiConfigsManager(): ApiConfigsManager - fun getUserWalletsListRepository(): UserWalletsListRepository - - fun getTangemHotSdk(): TangemHotSdk - fun getWcInitializeUseCase(): WcInitializeUseCase - fun getTrackingContextProxy(): TrackingContextProxy - fun getABTestsManager(): ABTestsManager fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory - fun getScanFailsRequester(): ScanFailsRequester - fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index ba43027039..61c9e07c43 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -13,46 +13,22 @@ import com.tangem.Log import com.tangem.TangemSdkLogger import com.tangem.blockchain.common.ExceptionHandler import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder -import com.tangem.blockchainsdk.BlockchainSDKFactory -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.common.routing.AppRouter import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.filter.AppsFlyerEventFilter import com.tangem.core.analytics.filter.OneTimeEventFilter import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.data.card.TransactionSignerFactory import com.tangem.datasource.api.common.MoshiConverter import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.createNetworkLoggingInterceptor -import com.tangem.datasource.connection.NetworkConnectionManager import com.tangem.datasource.local.config.environment.EnvironmentConfig -import com.tangem.datasource.local.config.issuers.IssuersConfigStorage import com.tangem.datasource.local.logs.AppLogsStore -import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.utils.NetworkLogsSaveInterceptor import com.tangem.datasource.utils.WireMockRedirectInterceptor -import com.tangem.domain.appcurrency.repository.AppCurrencyRepository import com.tangem.domain.apptheme.GetAppThemeModeUseCase -import com.tangem.domain.apptheme.repository.AppThemeModeRepository -import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository -import com.tangem.domain.card.ScanCardProcessor -import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.common.LogConfig -import com.tangem.domain.feedback.GetWalletMetaInfoUseCase -import com.tangem.domain.feedback.SendFeedbackEmailUseCase -import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.repository.OnboardingRepository -import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.operations.attestation.api.TangemApiServiceSettings import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder @@ -64,20 +40,12 @@ import com.tangem.tap.common.analytics.handlers.customerio.CustomerIoAnalyticsHa import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler import com.tangem.tap.common.images.createCoilImageLoader import com.tangem.tap.common.log.TangemAppLoggerInitializer -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.common.redux.appReducer -import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles -import com.tangem.tap.proxy.AppStateHolder -import com.tangem.tap.proxy.redux.DaggerGraphState import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch -import org.rekotlin.Store - -lateinit var store: Store lateinit var walletsRepository: WalletsRepository @@ -89,12 +57,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val entryPoint: ApplicationEntryPoint get() = EntryPoints.get(this, ApplicationEntryPoint::class.java) - private val appStateHolder: AppStateHolder - get() = entryPoint.getAppStateHolder() - - private val issuersConfigStorage: IssuersConfigStorage - get() = entryPoint.getIssuersConfigStorage() - private val environmentConfig: EnvironmentConfig get() = entryPoint.getEnvironmentConfig() @@ -104,96 +66,21 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val excludedBlockchainsManager: ExcludedBlockchainsManager get() = entryPoint.getExcludedBlockchainsManager() - private val networkConnectionManager: NetworkConnectionManager - get() = entryPoint.getNetworkConnectionManager() - - private val cardScanningFeatureToggles: CardScanningFeatureToggles - get() = entryPoint.getCardScanningFeatureToggles() - - private val scanCardProcessor: ScanCardProcessor - get() = entryPoint.getScanCardProcessor() - - private val appCurrencyRepository: AppCurrencyRepository - get() = entryPoint.getAppCurrencyRepository() - - private val walletManagersFacade: WalletManagersFacade - get() = entryPoint.getWalletManagersFacade() - - private val appThemeModeRepository: AppThemeModeRepository - get() = entryPoint.getAppThemeModeRepository() - - private val balanceHidingRepository: BalanceHidingRepository - get() = entryPoint.getBalanceHidingRepository() - - private val appPreferencesStore: AppPreferencesStore - get() = entryPoint.getAppPreferencesStore() - val getAppThemeModeUseCase: GetAppThemeModeUseCase get() = entryPoint.getGetAppThemeModeUseCase() private val oneTimeEventFilter: OneTimeEventFilter get() = entryPoint.getOneTimeEventFilter() - private val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase - get() = entryPoint.getWasTwinsOnboardingShownUseCase() - - private val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase - get() = entryPoint.getSaveTwinsOnboardingShownUseCase() - - private val cardRepository: CardRepository - get() = entryPoint.getCardRepository() - private val tangemSdkLogger: TangemSdkLogger get() = entryPoint.getTangemSdkLogger() - private val settingsRepository: SettingsRepository - get() = entryPoint.getSettingsRepository() - - private val blockchainSDKFactory: BlockchainSDKFactory - get() = entryPoint.getBlockchainSDKFactory() - - private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase - get() = entryPoint.getSendFeedbackEmailUseCase() - - private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase - get() = entryPoint.getWalletMetaInfoUseCase() - - private val urlOpener - get() = entryPoint.getUrlOpener() - - private val shareManager - get() = entryPoint.getShareManager() - - private val appRouter: AppRouter - get() = entryPoint.getAppRouter() - private val tangemAppLoggerInitializer: TangemAppLoggerInitializer get() = entryPoint.getTangemAppLogger() - private val transactionSignerFactory: TransactionSignerFactory - get() = entryPoint.getTransactionSignerFactory() - - private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles - get() = entryPoint.getOnboardingV2FeatureToggles() - - private val onboardingRepository: OnboardingRepository - get() = entryPoint.getOnboardingRepository() - - private val excludedBlockchains: ExcludedBlockchains - get() = entryPoint.getExcludedBlockchains() - private val appLogsStore: AppLogsStore get() = entryPoint.getAppLogsStore() - private val clipboardManager: ClipboardManager - get() = entryPoint.getClipboardManager() - - private val settingsManager: SettingsManager - get() = entryPoint.getSettingsManager() - - private val uiMessageSender: UiMessageSender - get() = entryPoint.getUiMessageSender() - private val blockchainExceptionHandler: BlockchainExceptionHandler get() = entryPoint.getBlockchainExceptionHandler() @@ -205,33 +92,18 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. .setWorkerFactory(workerFactory) .build() - private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory - get() = entryPoint.getColdUserWalletBuilderFactory() - private val apiConfigsManager: ApiConfigsManager get() = entryPoint.getApiConfigsManager() - private val userWalletsListRepository - get() = entryPoint.getUserWalletsListRepository() - - private val tangemHotSdk - get() = entryPoint.getTangemHotSdk() - private val wcInitializeUseCase get() = entryPoint.getWcInitializeUseCase() - private val trackingContextProxy - get() = entryPoint.getTrackingContextProxy() - private val abTestsManager: ABTestsManager get() = entryPoint.getABTestsManager() private val appsFlyerClientFactory: AppsFlyerClient.Factory get() = entryPoint.getAppsFlyerClientFactory() - private val scanFailsRequester - get() = entryPoint.getScanFailsRequester() - private val sendTransactionSignerInfoInterceptor get() = entryPoint.getSendTransactionSignerInfoInterceptor() @@ -279,8 +151,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. apiConfigsManager.initialize() - store = createReduxStore() - TangemLogger.i("APP STARTED") if (BuildConfig.TESTER_MENU_ENABLED) { TangemLogger.i(featureTogglesManager.toString()) @@ -321,57 +191,11 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. ) } - appStateHolder.mainStore = store - wcInitializeUseCase.init( projectId = environmentConfig.walletConnectProjectId, ) } - private fun createReduxStore(): Store { - return Store( - reducer = { action, state -> appReducer(action, requireNotNull(state)) }, - middleware = AppState.getMiddleware(), - state = AppState( - daggerGraphState = DaggerGraphState( - networkConnectionManager = networkConnectionManager, - cardScanningFeatureToggles = cardScanningFeatureToggles, - scanCardProcessor = scanCardProcessor, - appCurrencyRepository = appCurrencyRepository, - walletManagersFacade = walletManagersFacade, - appStateHolder = appStateHolder, - appThemeModeRepository = appThemeModeRepository, - balanceHidingRepository = balanceHidingRepository, - walletsRepository = walletsRepository, - wasTwinsOnboardingShownUseCase = wasTwinsOnboardingShownUseCase, - saveTwinsOnboardingShownUseCase = saveTwinsOnboardingShownUseCase, - cardRepository = cardRepository, - settingsRepository = settingsRepository, - blockchainSDKFactory = blockchainSDKFactory, - sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, - getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, - issuersConfigStorage = issuersConfigStorage, - urlOpener = urlOpener, - shareManager = shareManager, - appRouter = appRouter, - transactionSignerFactory = transactionSignerFactory, - onboardingV2FeatureToggles = onboardingV2FeatureToggles, - onboardingRepository = onboardingRepository, - excludedBlockchains = excludedBlockchains, - appPreferencesStore = appPreferencesStore, - clipboardManager = clipboardManager, - settingsManager = settingsManager, - uiMessageSender = uiMessageSender, - coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, - userWalletsListRepository = userWalletsListRepository, - tangemHotSdk = tangemHotSdk, - trackingContextProxy = trackingContextProxy, - scanFailsRequester = scanFailsRequester, - ), - ), - ) - } - private fun updateLogFiles() { appLogsStore.deleteOldLogsFile() diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Store.kt b/app/src/main/java/com/tangem/tap/common/extensions/Store.kt deleted file mode 100644 index 8d38cb9f3a..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/Store.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.tangem.tap.common.extensions - -import com.tangem.common.routing.AppRouter -import com.tangem.domain.common.extensions.withMainContext -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.scope -import com.tangem.tap.store -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.rekotlin.Action -import org.rekotlin.Store - -/** - * Dispatch action with creating the new coroutine with the Main dispatcher - * - * @see dispatchWithMain - */ -fun Store<*>.dispatchOnMain(action: Action) { - scope.launch(Dispatchers.Main) { - dispatch(action) - } -} - -/** - * Dispatch action on the Main coroutine context - * - * @param action [Action] to be dispatched - * - * @see dispatchOnMain - * */ -suspend fun Store<*>.dispatchWithMain(action: Action) { - withMainContext { - dispatch(action) - } -} - -suspend fun Store.onUserWalletSelected(userWallet: UserWallet) { - state.globalState.tapWalletManager.onWalletSelected(userWallet) -} - -/** - * Dispatch action inside a coroutine with the Main dispatcher - */ -@Deprecated( - message = "Use dispatchWithMain instead", - replaceWith = ReplaceWith(expression = "dispatchWithMain"), -) -suspend fun dispatchOnMain(vararg actions: Action) { - withMainContext { actions.forEach { store.dispatch(it) } } -} - -fun Store.dispatchNavigationAction(action: AppRouter.() -> Unit) { - inject(DaggerGraphState::appRouter).action() -} - -inline fun Store.inject(getDependency: DaggerGraphState.() -> T?): T { - return requireNotNull(state.daggerGraphState.getDependency()) { - "${T::class.simpleName.orEmpty()} isn't initialized " - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt deleted file mode 100644 index 57be8c6e21..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/AccessCodeRequestPolicyMiddleware.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.mainScope -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import kotlinx.coroutines.launch -import org.rekotlin.Middleware - -class AccessCodeRequestPolicyMiddleware { - val middleware: Middleware = { _, _ -> - { next -> - { action -> - if (action is GlobalAction.SaveScanResponse) { - updateAccessCodeRequestPolicy(action.scanResponse) - } - next(action) - } - } - } - - private fun updateAccessCodeRequestPolicy(scanResponse: ScanResponse) { - mainScope.launch { - val shouldSaveAccessCodes = store.inject(DaggerGraphState::settingsRepository).shouldSaveAccessCodes() - - store.inject(DaggerGraphState::cardSdkConfigRepository).setAccessCodeRequestPolicy( - isBiometricsRequestPolicy = shouldSaveAccessCodes && scanResponse.card.isAccessCodeSet, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt deleted file mode 100644 index 092abd503b..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/AppReducer.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.tap.common.redux.global.globalReducer -import com.tangem.tap.proxy.redux.DaggerGraphReducer -import org.rekotlin.Action - -fun appReducer(action: Action, state: AppState): AppState { - if (action is AppAction.RestoreState) return action.state - - return AppState( - globalState = globalReducer(action, state), - daggerGraphState = DaggerGraphReducer.reduce(action, state), - ) -} - -sealed class AppAction : Action { - data class RestoreState(val state: AppState) : AppAction() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt b/app/src/main/java/com/tangem/tap/common/redux/AppState.kt deleted file mode 100644 index d854ba1088..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/AppState.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.tap.common.redux.global.GlobalState -import com.tangem.tap.proxy.redux.DaggerGraphMiddleware -import com.tangem.tap.proxy.redux.DaggerGraphState -import org.rekotlin.Middleware -import org.rekotlin.StateType - -data class AppState( - val globalState: GlobalState = GlobalState(), - val daggerGraphState: DaggerGraphState = DaggerGraphState(), -) : StateType { - - companion object { - fun getMiddleware(): List> { - return listOf( - logMiddleware, - LockUserWalletsTimerMiddleware().middleware, - AccessCodeRequestPolicyMiddleware().middleware, - DaggerGraphMiddleware.daggerGraphMiddleware, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/LockUserWalletsTimerMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/LockUserWalletsTimerMiddleware.kt deleted file mode 100644 index 67bb53a5b5..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/LockUserWalletsTimerMiddleware.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.tap.lockUserWalletsTimer -import org.rekotlin.Middleware - -class LockUserWalletsTimerMiddleware { - val middleware: Middleware = { _, _ -> - { nextDispatch -> - { action -> - lockUserWalletsTimer?.restart() - nextDispatch(action) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt b/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt deleted file mode 100644 index 7dd3992c54..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/LogMiddleware.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tangem.tap.common.redux - -import com.tangem.utils.logging.TangemLogger -import org.rekotlin.Middleware - -/** -[REDACTED_AUTHOR] - */ -val logMiddleware: Middleware = { _, _ -> - { nextDispatch -> - { action -> - TangemLogger.i("Dispatch action: ${action::class.java.simpleName}") - nextDispatch(action) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt deleted file mode 100644 index c446ff5206..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalAction.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.common.redux.global - -import com.tangem.domain.models.scan.ScanResponse -import org.rekotlin.Action - -sealed class GlobalAction : Action { - - data class SaveScanResponse(val scanResponse: ScanResponse) : GlobalAction() - - data class IsSignWithRing(val isSignWithRing: Boolean) : GlobalAction() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt deleted file mode 100644 index 32164696dc..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalReducer.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.tap.common.redux.global - -import com.tangem.tap.common.redux.AppState -import org.rekotlin.Action - -@Suppress("LongMethod", "ComplexMethod") -fun globalReducer(action: Action, state: AppState): GlobalState { - if (action !is GlobalAction) return state.globalState - - val globalState = state.globalState - - return when (action) { - is GlobalAction.SaveScanResponse -> { - globalState.copy(scanResponse = action.scanResponse) - } - is GlobalAction.IsSignWithRing -> globalState.copy(isLastSignWithRing = action.isSignWithRing) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt b/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt deleted file mode 100644 index 843d9f2977..0000000000 --- a/app/src/main/java/com/tangem/tap/common/redux/global/GlobalState.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.tap.common.redux.global - -import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.domain.TapWalletManager -import org.rekotlin.StateType - -data class GlobalState( - @Deprecated("Use scan response from selected user wallet") - val scanResponse: ScanResponse? = null, - val tapWalletManager: TapWalletManager = TapWalletManager(), - val isLastSignWithRing: Boolean = false, -) : StateType - -typealias CryptoCurrencyName = String \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/AppStateHolderModule.kt b/app/src/main/java/com/tangem/tap/di/AppStateHolderModule.kt deleted file mode 100644 index 267fa733f1..0000000000 --- a/app/src/main/java/com/tangem/tap/di/AppStateHolderModule.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.tap.di - -import com.tangem.domain.redux.ReduxStateHolder -import com.tangem.tap.proxy.AppStateHolder -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface AppStateHolderModule { - - @Binds - @Singleton - fun bindsReduxStateHolder(appStateHolder: AppStateHolder): ReduxStateHolder -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt b/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt deleted file mode 100644 index f486cce85c..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/TapWalletManager.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.tangem.tap.domain - -import com.tangem.blockchain.common.Token -import com.tangem.blockchain.common.Wallet -import com.tangem.domain.common.extensions.withMainContext -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.tap.common.extensions.inject -import com.tangem.tap.common.redux.global.GlobalAction -import com.tangem.tap.proxy.redux.DaggerGraphState -import com.tangem.tap.store -import com.tangem.tap.tangemSdkManager -import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -class TapWalletManager( - private val dispatchers: CoroutineDispatcherProvider = AppCoroutineDispatcherProvider(), -) { - - private var loadUserWalletDataJob: Job? = null - set(value) { - field?.cancel() - field = value - } - - suspend fun onWalletSelected(userWallet: UserWallet) { - // If a previous job was running, it gets cancelled before the new one starts, - // ensuring that only one job is active at any given time. - loadUserWalletDataJob = CoroutineScope(dispatchers.io) - .launch { loadUserWalletData(userWallet) } - .apply { join() } - } - - /** - * [REDACTED_TODO_COMMENT] - */ - private suspend fun loadUserWalletData(userWallet: UserWallet) { - val trackingContextProxy = store.inject(DaggerGraphState::trackingContextProxy) - trackingContextProxy.setContext(userWallet) - - if (userWallet is UserWallet.Cold) { - val scanResponse = userWallet.scanResponse - tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse) - withMainContext { - // Order is important - store.dispatch(GlobalAction.SaveScanResponse(scanResponse)) - } - } - } -} - -fun Wallet.getFirstToken(): Token? = getTokens().toList().getOrNull(index = 0) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/model/Currency.kt b/app/src/main/java/com/tangem/tap/domain/model/Currency.kt index 29c89811fc..4de663c36c 100644 --- a/app/src/main/java/com/tangem/tap/domain/model/Currency.kt +++ b/app/src/main/java/com/tangem/tap/domain/model/Currency.kt @@ -1,12 +1,11 @@ package com.tangem.tap.domain.model -import com.tangem.tap.common.redux.global.CryptoCurrencyName import com.tangem.blockchain.common.Blockchain as SdkBlockchain import com.tangem.blockchain.common.Token as SdkToken sealed interface Currency { val blockchain: SdkBlockchain - val currencySymbol: CryptoCurrencyName + val currencySymbol: String val derivationPath: String? val decimals get() = when (this) { @@ -26,6 +25,6 @@ sealed interface Currency { override val blockchain: SdkBlockchain, override val derivationPath: String?, ) : Currency { - override val currencySymbol: CryptoCurrencyName = blockchain.currency + override val currencySymbol: String = blockchain.currency } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/demo/DemoMiddleware.kt b/app/src/main/java/com/tangem/tap/features/demo/DemoMiddleware.kt deleted file mode 100644 index 4ddd62e7f3..0000000000 --- a/app/src/main/java/com/tangem/tap/features/demo/DemoMiddleware.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.tap.features.demo - -import com.tangem.domain.demo.models.DemoConfig -import com.tangem.domain.models.scan.ScanResponse -import org.rekotlin.Action - -/** -[REDACTED_AUTHOR] - */ -interface DemoMiddleware { - fun tryHandle(config: DemoConfig, scanResponse: ScanResponse, action: Action): Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt b/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt deleted file mode 100644 index e85f806bdc..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/AppStateHolder.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.tangem.tap.proxy - -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.redux.ReduxStateHolder -import com.tangem.tap.common.extensions.dispatchWithMain -import com.tangem.tap.common.extensions.onUserWalletSelected -import com.tangem.tap.common.redux.AppState -import com.tangem.tap.network.exchangeServices.SellService -import org.rekotlin.Action -import org.rekotlin.Store -import javax.inject.Inject - -/** - * Holds objects from old modules, that missing in DI graph. - * Object sets manually to use in new modules and [AppStateHolder] proxies its to DI. - */ -class AppStateHolder @Inject constructor() : ReduxStateHolder { - - var mainStore: Store? = null - var sellService: SellService? = null - - override fun dispatch(action: Action) { - mainStore?.dispatch(action) - } - - override suspend fun dispatchWithMain(action: Action) { - mainStore?.dispatchWithMain(action) - } - - override suspend fun onUserWalletSelected(userWallet: UserWallet) { - mainStore?.onUserWalletSelected(userWallet) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt b/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt deleted file mode 100644 index 5c943d917b..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/di/ProxyModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tangem.tap.proxy.di - -import com.tangem.tap.proxy.AppStateHolder -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object ProxyModule { - - @Provides - @Singleton - fun provideAppStateHolder(): AppStateHolder { - return AppStateHolder() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt deleted file mode 100644 index ef0fab7352..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphAction.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.proxy.redux - -import com.tangem.domain.card.ScanCardUseCase -import com.tangem.domain.card.repository.CardSdkConfigRepository -import org.rekotlin.Action - -sealed interface DaggerGraphAction : Action { - - data class SetActivityDependencies( - val scanCardUseCase: ScanCardUseCase, - val cardSdkConfigRepository: CardSdkConfigRepository, - ) : DaggerGraphAction -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt deleted file mode 100644 index 0e822a4edc..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphMiddleware.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.proxy.redux - -import com.tangem.tap.common.redux.AppState -import org.rekotlin.Middleware - -@Suppress("MemberNameEqualsClassName") -object DaggerGraphMiddleware { - val daggerGraphMiddleware: Middleware = { _, _ -> - { next -> - { action -> next(action) } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt deleted file mode 100644 index 6a2e43b96a..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphReducer.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.tap.proxy.redux - -import com.tangem.tap.common.redux.AppState -import org.rekotlin.Action - -object DaggerGraphReducer { - fun reduce(action: Action, state: AppState): DaggerGraphState { - if (action !is DaggerGraphAction) return state.daggerGraphState - - return internalReduce(action, state) - } - - private fun internalReduce(action: DaggerGraphAction, state: AppState): DaggerGraphState { - return when (action) { - is DaggerGraphAction.SetActivityDependencies -> state.daggerGraphState.copy( - scanCardUseCase = action.scanCardUseCase, - cardSdkConfigRepository = action.cardSdkConfigRepository, - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt b/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt deleted file mode 100644 index 9f31ff291c..0000000000 --- a/app/src/main/java/com/tangem/tap/proxy/redux/DaggerGraphState.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.tangem.tap.proxy.redux - -import com.tangem.blockchainsdk.BlockchainSDKFactory -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.common.routing.AppRouter -import com.tangem.core.analytics.utils.TrackingContextProxy -import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.settings.SettingsManager -import com.tangem.core.navigation.share.ShareManager -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.data.card.TransactionSignerFactory -import com.tangem.datasource.connection.NetworkConnectionManager -import com.tangem.datasource.local.config.issuers.IssuersConfigStorage -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.domain.appcurrency.repository.AppCurrencyRepository -import com.tangem.domain.apptheme.repository.AppThemeModeRepository -import com.tangem.domain.balancehiding.repositories.BalanceHidingRepository -import com.tangem.domain.card.ScanCardProcessor -import com.tangem.domain.card.ScanCardUseCase -import com.tangem.domain.card.ScanFailsRequester -import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.feedback.GetWalletMetaInfoUseCase -import com.tangem.domain.feedback.SendFeedbackEmailUseCase -import com.tangem.domain.onboarding.SaveTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase -import com.tangem.domain.onboarding.repository.OnboardingRepository -import com.tangem.domain.settings.repositories.SettingsRepository -import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles -import com.tangem.hot.sdk.TangemHotSdk -import com.tangem.operations.attestation.CardArtworksProvider -import com.tangem.tap.domain.scanCard.CardScanningFeatureToggles -import com.tangem.tap.proxy.AppStateHolder -import org.rekotlin.StateType - -data class DaggerGraphState( - val networkConnectionManager: NetworkConnectionManager? = null, - val cardScanningFeatureToggles: CardScanningFeatureToggles? = null, - val scanCardUseCase: ScanCardUseCase? = null, - val scanCardProcessor: ScanCardProcessor? = null, - val cardSdkConfigRepository: CardSdkConfigRepository? = null, - val appCurrencyRepository: AppCurrencyRepository? = null, - val walletManagersFacade: WalletManagersFacade? = null, - val appStateHolder: AppStateHolder? = null, - val appThemeModeRepository: AppThemeModeRepository? = null, - val balanceHidingRepository: BalanceHidingRepository? = null, - val walletsRepository: WalletsRepository? = null, - val wasTwinsOnboardingShownUseCase: WasTwinsOnboardingShownUseCase? = null, - val saveTwinsOnboardingShownUseCase: SaveTwinsOnboardingShownUseCase? = null, - val cardRepository: CardRepository? = null, - val settingsRepository: SettingsRepository? = null, - val blockchainSDKFactory: BlockchainSDKFactory? = null, - val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase? = null, - val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase? = null, - val issuersConfigStorage: IssuersConfigStorage? = null, - val urlOpener: UrlOpener? = null, - val shareManager: ShareManager? = null, - val appRouter: AppRouter? = null, - val transactionSignerFactory: TransactionSignerFactory? = null, - val onboardingV2FeatureToggles: OnboardingV2FeatureToggles? = null, - val onboardingRepository: OnboardingRepository? = null, - val excludedBlockchains: ExcludedBlockchains? = null, - val appPreferencesStore: AppPreferencesStore? = null, - val clipboardManager: ClipboardManager? = null, - val settingsManager: SettingsManager? = null, - val uiMessageSender: UiMessageSender? = null, - val cardArworksProvider: CardArtworksProvider? = null, - val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory? = null, - val userWalletsListRepository: UserWalletsListRepository? = null, - val tangemHotSdk: TangemHotSdk? = null, - val trackingContextProxy: TrackingContextProxy? = null, - val scanFailsRequester: ScanFailsRequester? = null, -) : StateType \ No newline at end of file diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts index 3020e44ee4..c2e83ab662 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -17,5 +17,4 @@ dependencies { kapt(deps.hilt.kapt) implementation(deps.material) - implementation(deps.reKotlin) } \ No newline at end of file diff --git a/domain/legacy/build.gradle.kts b/domain/legacy/build.gradle.kts index 6d09dae413..59b91dd6a8 100644 --- a/domain/legacy/build.gradle.kts +++ b/domain/legacy/build.gradle.kts @@ -42,7 +42,6 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.moshi) implementation(deps.moshi.kotlin) - implementation(deps.reKotlin) ksp(deps.moshi.kotlin.codegen) /** Testing libraries */ diff --git a/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt b/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt deleted file mode 100644 index 3a96226153..0000000000 --- a/domain/legacy/src/main/java/com/tangem/domain/redux/ReduxStateHolder.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.domain.redux - -import com.tangem.domain.models.wallet.UserWallet -import org.rekotlin.Action - -interface ReduxStateHolder { - - fun dispatch(action: Action) - - suspend fun dispatchWithMain(action: Action) - - suspend fun onUserWalletSelected(userWallet: UserWallet) -} \ No newline at end of file diff --git a/domain/tokens/build.gradle.kts b/domain/tokens/build.gradle.kts index 236ce7785f..5fc40e8cea 100644 --- a/domain/tokens/build.gradle.kts +++ b/domain/tokens/build.gradle.kts @@ -56,7 +56,6 @@ dependencies { /** Utils */ implementation(deps.jodatime) - implementation(deps.reKotlin) implementation(tangemDeps.blockchain) { exclude(module = "joda-time") diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 7fc4587458..e2f9062600 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -78,7 +78,6 @@ dependencies { /* Other */ implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) implementation(deps.arrow.core) implementation(deps.arrow.fx) } \ No newline at end of file diff --git a/features/manage-tokens/impl/build.gradle.kts b/features/manage-tokens/impl/build.gradle.kts index 5c8bc1a2ed..f2e5b7170c 100644 --- a/features/manage-tokens/impl/build.gradle.kts +++ b/features/manage-tokens/impl/build.gradle.kts @@ -69,5 +69,4 @@ dependencies { /* Other */ implementation(deps.kotlin.immutable.collections) implementation(deps.decompose.ext.compose) - implementation(deps.reKotlin) // need for legacy onboarding } \ No newline at end of file diff --git a/features/onramp/impl/build.gradle.kts b/features/onramp/impl/build.gradle.kts index 60286429b9..87252298e9 100644 --- a/features/onramp/impl/build.gradle.kts +++ b/features/onramp/impl/build.gradle.kts @@ -82,5 +82,4 @@ dependencies { /** Other */ implementation(deps.decompose.ext.compose) implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) } \ No newline at end of file diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index c47f2c68ed..379ae600f2 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -37,7 +37,6 @@ dependencies { implementation(deps.arrow.core) implementation(deps.jodatime) implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) implementation(tangemDeps.blockchain) implementation(tangemDeps.card.core) implementation(deps.lifecycle.compose) diff --git a/features/wallet-settings/impl/build.gradle.kts b/features/wallet-settings/impl/build.gradle.kts index 51e2d587f8..073dc92b0f 100644 --- a/features/wallet-settings/impl/build.gradle.kts +++ b/features/wallet-settings/impl/build.gradle.kts @@ -73,7 +73,6 @@ dependencies { /* Other */ implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) /** Tangem libraries */ implementation(tangemDeps.hot.core) diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 60c99dd9a8..83c646bcc9 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -41,7 +41,6 @@ dependencies { implementation(deps.googlePlay.review) implementation(deps.jodatime) implementation(deps.kotlin.immutable.collections) - implementation(deps.reKotlin) implementation(tangemDeps.hot.core) implementation(tangemDeps.card.core) implementation(tangemDeps.blockchain) diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 32ce0df01b..cb69868e03 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -73,7 +73,6 @@ lottie-compose = "6.6.0" moshi = "1.15.1" moshiAdaptersExt = "0.1.5" okhttp = "4.9.3" -rekotlin = "1.0.4" retrofit = "2.11.0" retrofitMoshiConverter = "2.9.0" spongycastleCryptoCore = "1.58.0.0" @@ -279,7 +278,6 @@ moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", ver okHttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okHttp-prettyLogging = { module = "com.github.ihsanbal:LoggingInterceptor", version.ref = "okHttp-prettyLogging" } spongecastle-core = { module = "com.madgag.spongycastle:core", version.ref = "spongycastleCryptoCore" } -reKotlin = { module = "org.rekotlin:rekotlin", version.ref = "rekotlin" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-response-type-keeper = { module = "com.squareup.retrofit2:response-type-keeper", version.ref = "retrofit" } retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofitMoshiConverter" } From fabd706b12780007db3424bcd1e2b6d52c610241 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 19:37:58 +0500 Subject: [PATCH 091/206] Updated on 2026-08-14 --- .../di/domain/AssetsDiscoveryDomainModule.kt | 3 +++ .../event/AssetsDiscoveryAnalyticsEvent.kt | 17 +++++++++++++++++ .../usecase/StartAssetsDiscoveryUseCase.kt | 5 +++++ .../model/intents/WalletWarningsClickIntents.kt | 3 +++ 4 files changed, 28 insertions(+) create mode 100644 core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/AssetsDiscoveryAnalyticsEvent.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt index 1745d88c51..9886ff8e2c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/AssetsDiscoveryDomainModule.kt @@ -1,5 +1,6 @@ package com.tangem.tap.di.domain +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.assetsdiscovery.repository.AssetsDiscoveryRepository import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase @@ -41,11 +42,13 @@ internal object AssetsDiscoveryDomainModule { fun provideStartAssetsDiscoveryUseCase( assetsDiscoveryRepository: AssetsDiscoveryRepository, manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, + analyticsEventHandler: AnalyticsEventHandler, appCoroutineScope: AppCoroutineScope, ): StartAssetsDiscoveryUseCase { return StartAssetsDiscoveryUseCase( assetsDiscoveryRepository = assetsDiscoveryRepository, manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, + analyticsEventHandler = analyticsEventHandler, appCoroutineScope = appCoroutineScope, ) } diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/AssetsDiscoveryAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/AssetsDiscoveryAnalyticsEvent.kt new file mode 100644 index 0000000000..2961459d3d --- /dev/null +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/AssetsDiscoveryAnalyticsEvent.kt @@ -0,0 +1,17 @@ +package com.tangem.core.analytics.models.event + +import com.tangem.core.analytics.models.AnalyticsEvent + +sealed class AssetsDiscoveryAnalyticsEvent( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Token Sync", event = event, params = params) { + + class SyncStarted : AssetsDiscoveryAnalyticsEvent(event = "Sync Started") + + class SyncCompleted : AssetsDiscoveryAnalyticsEvent(event = "Sync Completed") + + class ButtonManageTokens : AssetsDiscoveryAnalyticsEvent(event = "Button - Manage Tokens") + + class ButtonCloseBanner : AssetsDiscoveryAnalyticsEvent(event = "Button - Close Banner") +} \ No newline at end of file diff --git a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt index 11df5ba962..5ba4a8ffe6 100644 --- a/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt +++ b/domain/assetsdiscovery/src/main/kotlin/com/tangem/domain/assetsdiscovery/usecase/StartAssetsDiscoveryUseCase.kt @@ -1,6 +1,8 @@ package com.tangem.domain.assetsdiscovery.usecase import arrow.core.Either +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWalletId @@ -15,6 +17,7 @@ class StartAssetsDiscoveryUseCase( private val assetsDiscoveryRepository: AssetsDiscoveryRepository, private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val appCoroutineScope: AppCoroutineScope, + private val analyticsEventHandler: AnalyticsEventHandler, ) { private val activeSyncJobs = ConcurrentHashMap() @@ -23,9 +26,11 @@ class StartAssetsDiscoveryUseCase( activeSyncJobs[userWalletId]?.cancel() activeSyncJobs[userWalletId] = appCoroutineScope.launch { try { + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncStarted()) assetsDiscoveryRepository.runDiscovery(userWalletId) applyDiscoveredTokens(userWalletId) assetsDiscoveryRepository.completeDiscovery(userWalletId) + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.SyncCompleted()) } catch (e: Exception) { TangemLogger.e("Token sync failed for wallet: $userWalletId", e) } finally { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index f3642a626e..7c66a4acc4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -9,6 +9,7 @@ import com.tangem.common.ui.userwallet.handle import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic.ButtonSupport +import com.tangem.core.analytics.models.event.AssetsDiscoveryAnalyticsEvent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.review.ReviewManager @@ -509,10 +510,12 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( } override fun onDismissAssetsDiscoveryNotification(userWalletId: UserWalletId) { + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.ButtonCloseBanner()) acknowledgeAssetsDiscoveryCompletionUseCase(userWalletId) } override fun onAssetsDiscoveryManageClick(userWalletId: UserWalletId) { + analyticsEventHandler.send(AssetsDiscoveryAnalyticsEvent.ButtonManageTokens()) acknowledgeAssetsDiscoveryCompletionUseCase(userWalletId) router.openManageTokensScreen( AccountId.forMainCryptoPortfolio(userWalletId), From 8f4e5ed3e066385bd3d3bf3b0640123c36cbde1c Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 18:38:24 +0400 Subject: [PATCH 092/206] Updated on 2026-08-14 --- .../express/models/ExpressProviderType.kt | 2 +- .../feature/swap/domain/SwapInteractorImpl.kt | 26 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt index 343c0bda7b..2974c8a2db 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt @@ -27,8 +27,8 @@ enum class ExpressProviderType(val typeName: String) { fun ExpressProviderType.shouldStoreSwapTransaction() = when (this) { CEX, DEX_BRIDGE, - -> true DEX, + -> true ONRAMP, -> false } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 9365e7893f..5b2b9b002e 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -904,19 +904,17 @@ internal class SwapInteractorImpl @AssistedInject constructor( txHash = txHash, payInExtraId = swapData.transaction.txExtraId, ) - if (provider.type == ExchangeProviderType.DEX_BRIDGE) { - val timestamp = System.currentTimeMillis() - storeSwapTransaction( - currencyToSend = currencyToSendStatus, - currencyToGet = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, - amount = amount, - swapProvider = provider, - swapDataModel = swapData, - timestamp = timestamp, - ) - } + val timestamp = System.currentTimeMillis() + storeSwapTransaction( + currencyToSend = currencyToSendStatus, + currencyToGet = currencyToGetStatus, + fromAccount = fromAccount, + toAccount = toAccount, + amount = amount, + swapProvider = provider, + swapDataModel = swapData, + timestamp = timestamp, + ) storeLastCryptoCurrencyId(currencyToGetStatus.currency) SwapTransactionState.TxSent( fromAmount = amountFormatter.formatSwapAmountToUI( @@ -930,7 +928,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( ), toAmountValue = swapData.toTokenAmount.value, txHash = txHash, - timestamp = System.currentTimeMillis(), + timestamp = timestamp, ) }, ifLeft = { SwapTransactionState.Error.TransactionError(it) }, From a96fb4b2f4b542df667c0fe6d7eef6c6d49d46ec Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 21 Apr 2026 15:25:55 +0300 Subject: [PATCH 093/206] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 1 + .../token/block/impl/ui/TokenMarketBlock.kt | 143 +++++++++++++++++- .../DefaultTokenDetailsComponent.kt | 1 + .../tokendetails/ui/TokenDetailsScreen.kt | 119 ++++++++++++--- .../ui/components/TokenDetailsBalanceBlock.kt | 21 ++- 5 files changed, 255 insertions(+), 30 deletions(-) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 2306a9be2e..da1a8d484c 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -768,6 +768,7 @@ This asset is not available for this wallet Add APY %s + Market Price My portfolio Market Staking & Yield mode diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt index 493d0af09c..66ce42ea67 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/token/block/impl/ui/TokenMarketBlock.kt @@ -1,25 +1,152 @@ package com.tangem.features.markets.token.block.impl.ui +import android.content.res.Configuration +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.charts.MarketChartMini +import com.tangem.common.ui.charts.state.MarketChartRawData +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.ds.row.token.internal.TokenRowPriceChangeContent +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.R as CoreR +import com.tangem.features.markets.impl.R +import com.tangem.features.markets.token.block.impl.model.formatter.toChartType import com.tangem.features.markets.token.block.impl.ui.state.TokenMarketBlockUM +import kotlinx.collections.immutable.toImmutableList +import kotlin.random.Random + +private val ChartWidth: Dp = 52.dp +private val ChartHeight: Dp = 32.dp -@Suppress("UnusedParameter") @Composable internal fun TokenMarketBlock(tokenMarketBlockUM: TokenMarketBlockUM, modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, + TangemRowContainer( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens2.x5)) + .background(TangemTheme.colors2.surface.level3), ) { Text( - text = "Market Block Redesign", - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.primary1, + text = stringResourceSafe(id = R.string.markets_common_market_price), + style = TangemTheme.typography2.bodySemibold16, + color = TangemTheme.colors2.text.neutral.primary, + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + ) { + Text( + text = tokenMarketBlockUM.currentPrice.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.primary, + ) + TokenRowPriceChangeContent( + priceChangeState = PriceChangeState.Content( + type = tokenMarketBlockUM.priceChangeType, + valueInPercent = tokenMarketBlockUM.h24Percent.orEmpty(), + ), + isFlickering = false, + ) + } + + Box( + modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), + ) { + val chartModifier = Modifier.requiredSize(width = ChartWidth, height = ChartHeight) + + if (tokenMarketBlockUM.chartData != null) { + MarketChartMini( + rawData = tokenMarketBlockUM.chartData, + type = tokenMarketBlockUM.priceChangeType.toChartType(), + modifier = chartModifier, + ) + } else { + RectangleShimmer(modifier = chartModifier) + } + } + + SecondaryTangemButton( + onClick = tokenMarketBlockUM.onClick, + tangemIconUM = TangemIconUM.Icon(iconRes = CoreR.drawable.ic_arrow_expand_24), + shape = TangemButtonShape.Rounded, + size = TangemButtonSize.X10, + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x10), ) } -} \ No newline at end of file +} + +// region Preview +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TokenMarketBlock_Preview( + @PreviewParameter(TokenMarketBlockPreviewProvider::class) params: TokenMarketBlockUM, +) { + TangemThemePreviewRedesign { + TokenMarketBlock( + tokenMarketBlockUM = params, + modifier = Modifier.background(TangemTheme.colors2.surface.level3), + ) + } +} + +private class TokenMarketBlockPreviewProvider : PreviewParameterProvider { + + private val data = MarketChartRawData( + x = List(size = 20) { Random.nextFloat().toDouble() }.toImmutableList(), + y = List(size = 20) { Random.nextFloat().toDouble() }.toImmutableList(), + ) + + private val state = TokenMarketBlockUM( + currencySymbol = "XRP", + currentPrice = "0,5$", + currentPriceValue = null, + priceAnnotated = null, + h24Percent = "0,5%", + priceChangeType = PriceChangeType.UP, + chartData = data, + onClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + state, + state.copy(currentPrice = "0,0000000000012356786789$"), + state.copy(currentPrice = null, chartData = null), + state.copy(chartData = null), + ) +} +// endregion \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index 639595b1f4..035e4511bf 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -97,6 +97,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( TokenDetailsScreen( tokenDetailsUM = tokenDetailsUM, + tokenMarketBlockComponent = tokenMarketBlockComponent, modifier = modifier, ) } else { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 14147b34e3..0783260183 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -3,9 +3,12 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.ui import android.content.res.Configuration import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars @@ -15,14 +18,22 @@ import com.tangem.common.ui.notifications.notifications import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.components.BottomFade import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem @@ -44,17 +55,22 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight +import com.tangem.features.markets.token.block.TokenMarketBlockComponent import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeTint import kotlinx.collections.immutable.persistentListOf -@Composable -internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifier = Modifier) { - val topAppBarUM = tokenDetailsUM.topAppBarUM +private val TopBarHeight: Dp = 64.dp +private val MarketBlockHorizontalPadding: Dp = 14.dp +@Composable +internal fun TokenDetailsScreen( + tokenDetailsUM: TokenDetailsUM, + tokenMarketBlockComponent: TokenMarketBlockComponent?, + modifier: Modifier = Modifier, +) { val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() } - val topBarHeight = 64.dp - val partialCollapsedHeight = topBarHeight + statusBarHeight + val partialCollapsedHeight = TopBarHeight + statusBarHeight val expandedHeight = TokenDetailsBalanceBlockHeight + partialCollapsedHeight val behavior = rememberTangemExitUntilCollapsedScrollBehavior( @@ -63,6 +79,7 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi ) val rootBackground by LocalRootBackgroundColor.current + var marketBlockHeight by remember { mutableStateOf(0.dp) } val notificationModifier = Modifier .fillMaxWidth() .padding(horizontal = TangemTheme.dimens2.x4) @@ -80,16 +97,18 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi collapsingPart = { TokenDetailsBalanceBlock( balanceBlockUM = tokenDetailsUM.balanceBlockUM, + behavior = behavior, modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(top = topBarHeight), + .padding(top = TopBarHeight), ) }, body = { TokenDetailsBody( tokenDetailsUM = tokenDetailsUM, rootBackground = rootBackground, + bottomContentPadding = marketBlockHeight, modifier = Modifier .fillMaxSize() .nestedScroll(behavior.nestedScrollConnection), @@ -99,33 +118,90 @@ internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifi ) } - val hazeIntensity by animateFloatAsState( - targetValue = (behavior.state.collapsedFraction * 2f).coerceIn(0f, 1f), - label = "TopBarHazeIntensity", + TokenDetailsTopBarOverlay( + topAppBarUM = tokenDetailsUM.topAppBarUM, + collapsedFraction = behavior.state.collapsedFraction, + rootBackground = rootBackground, ) - Box( - modifier = Modifier.hazeEffectTangem { - fallbackTint = HazeTint(rootBackground.copy(alpha = hazeIntensity / 2f)) - progressive = HazeProgressive.verticalGradient( - startIntensity = hazeIntensity, - endIntensity = 0f, - preferPerformance = true, - ) - }, - ) { - TokenDetailsTopBar(topAppBarUM = topAppBarUM) + + if (tokenMarketBlockComponent != null) { + TokenDetailsMarketBlockOverlay( + component = tokenMarketBlockComponent, + rootBackground = rootBackground, + onHeightChange = { marketBlockHeight = it }, + ) } } } +@Composable +private fun TokenDetailsTopBarOverlay( + topAppBarUM: TokenDetailsTopAppBarUM, + collapsedFraction: Float, + rootBackground: Color, +) { + val hazeIntensity by animateFloatAsState( + targetValue = (collapsedFraction * 2f).coerceIn(0f, 1f), + label = "TopBarHazeIntensity", + ) + Box( + modifier = Modifier.hazeEffectTangem { + fallbackTint = HazeTint(rootBackground.copy(alpha = hazeIntensity / 2f)) + progressive = HazeProgressive.verticalGradient( + startIntensity = hazeIntensity, + endIntensity = 0f, + preferPerformance = true, + ) + }, + ) { + TokenDetailsTopBar(topAppBarUM = topAppBarUM) + } +} + +@Composable +private fun BoxScope.TokenDetailsMarketBlockOverlay( + component: TokenMarketBlockComponent, + rootBackground: Color, + onHeightChange: (Dp) -> Unit, +) { + val density = LocalDensity.current + + BottomFade( + gradientBrush = Brush.verticalGradient( + colors = listOf( + rootBackground.copy(alpha = 0f), + rootBackground, + ), + ), + modifier = Modifier.align(Alignment.BottomCenter), + ) + + component.Content( + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { size -> + onHeightChange(with(density) { size.height.toDp() }) + } + .navigationBarsPadding() + .padding( + horizontal = MarketBlockHorizontalPadding, + vertical = TangemTheme.dimens2.x1_5, + ), + ) +} + @Composable private fun TokenDetailsBody( tokenDetailsUM: TokenDetailsUM, rootBackground: Color, + bottomContentPadding: Dp, modifier: Modifier = Modifier, itemModifier: Modifier = Modifier, ) { - LazyColumn(modifier = modifier) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(bottom = bottomContentPadding), + ) { notifications( notifications = tokenDetailsUM.notifications, contentColor = rootBackground, @@ -150,6 +226,7 @@ private fun TokenDetailsBody( private fun TokenDetailsScreen_Preview() { TangemThemePreviewRedesign { TokenDetailsScreen( + tokenMarketBlockComponent = null, tokenDetailsUM = TokenDetailsUM( topAppBarUM = TokenDetailsTopAppBarUM( titleState = TitleState.WithAccount( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt index ee3f598efc..5de00c3eb0 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenDetailsBalanceBlock.kt @@ -16,6 +16,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview @@ -31,6 +33,9 @@ import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.button.action.ActionButtons import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior +import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior +import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference @@ -45,13 +50,26 @@ import kotlinx.collections.immutable.persistentListOf private val CurrencyIconSize: Dp = 70.dp private val NetworkBadgeSize: Dp = 24.dp internal val TokenDetailsBalanceBlockHeight: Dp = 404.dp +private const val MIN_SCALE = 0.75f +private const val MAX_SCALE = 1f @Composable -internal fun TokenDetailsBalanceBlock(balanceBlockUM: TokenDetailsBalanceBlockUM, modifier: Modifier = Modifier) { +internal fun TokenDetailsBalanceBlock( + balanceBlockUM: TokenDetailsBalanceBlockUM, + behavior: TangemCollapsingAppBarBehavior, + modifier: Modifier = Modifier, +) { val rootBackground by LocalRootBackgroundColor.current + val collapsedFraction = behavior.state.collapsedFraction + val alpha = 1f - collapsedFraction + val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE) + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier + .alpha(alpha) + .scale(scale) + .snapToExitUntilCollapsed(behavior) .fillMaxWidth() .padding(vertical = TangemTheme.dimens2.x10), ) { @@ -165,6 +183,7 @@ private fun TokenDetailsBalanceBlock_Preview( TangemThemePreviewRedesign { TokenDetailsBalanceBlock( balanceBlockUM = params, + behavior = rememberTangemExitUntilCollapsedScrollBehavior(), modifier = Modifier.background(TangemTheme.colors2.surface.level2), ) } From 721115e64e60f1b1ee0e656666b60a5435d910c0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Apr 2026 13:17:34 +0100 Subject: [PATCH 094/206] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 70 +++++++++++++------ .../DefaultAddressSyncComponent.kt | 14 +--- .../v2/addresssync/model/AddressSyncIntent.kt | 2 +- .../v2/addresssync/model/AddressSyncModel.kt | 56 +++++++++++---- .../addresssync/navigation/AddressSyncStep.kt | 11 +-- .../DefaultOnboardingMultiWalletComponent.kt | 1 + .../impl/model/OnboardingMultiWalletModel.kt | 7 +- .../v2/multiwallet/impl/model/Utils.kt | 2 +- .../addresssync/model/AddressSyncModelTest.kt | 53 ++++++++++++-- 9 files changed, 160 insertions(+), 56 deletions(-) diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index da1a8d484c..b40fb7272c 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -274,6 +274,10 @@ day days + + %d day ago + %d days ago + Delete Disable Disabled @@ -308,6 +312,10 @@ Hide Hold to %s hour + + %dh ago + %dh ago + Import In progress Insufficient balance @@ -318,6 +326,10 @@ Locked Locked Wallets Main network + + %d minute ago + %d minutes ago + month Network fee Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level @@ -487,7 +499,6 @@ Disable dynamic addresses Dynamic addresses disabled Dynamic addresses enabled - Dynamic address Use a new address for each transaction to reduce traceability and improve on-chain privacy. Enhanced Privacy Easily receive funds in UTXO-based networks with automatic address generation — no manual address management required. @@ -498,6 +509,9 @@ Dynamic Addresses Unavailable We can’t connect to the provider right now. Please try again later. Service unavailable. Please try again. + Funds were found on additional addresses. Enable Dynamic Addresses to access them. + Funds found on additional addresses + Dynamic address Best opportunities Clear filter The list is temporarily empty as it’s being refreshed. Check back in a moment. @@ -569,6 +583,7 @@ Provider Best rate FCA Warning List + Fixed rate is unavailable Competitive rate Provider in FCA warning list Available up to %s @@ -781,6 +796,7 @@ No data **Add to your portfolio** to start buying, exchanging or receiving this asset In your portfolio + Your portfolio Market Pulse Quick actions Clear all @@ -1020,7 +1036,9 @@ Getting started Another wallet has already been created on the card you\'re trying to add. If you have funds in this wallet, please withdraw it and then reset this card and add it as a backup. Save your wallet + Use biometrics Creating a backup + Last step Biometrics Read more about seed phrase @@ -1516,13 +1534,21 @@ Feel confident with round-the-clock support to help with any issues Always Here Multiple trusted providers in one place—swap any asset effortlessly in your wallet + Exchange crypto directly in Tangem\nNo additional transfers
\nNo moving funds to exchanges Swap With Us + Swap Inside Your Wallet No fumbles, no turnovers, no blind spots—your transaction is always protected + Swaps are executed through trusted providers. Your keys remain in your Tangem wallet at all times. 
Clear. Transparent. Self-custodial. Impenetrable Defense + You Stay in Control Maximize your value with rates sourced from a wide network of trusted providers, always choosing the best one + Tangem compares multiple providers, both DEX and CEX. The best rate is selected automatically. Prefer another provider? You can choose it manually. Unbeatable Rates + Best Available Rate Hassle-free and intuitive, allowing you to swap tokens in just a few taps + Swap across major networks
and thousands of tokens 0% fee on stablecoin-to-stablecoin swaps Simply Convenient + 90+ Blockchains
\n16,000+ Assets Swap via provider Your assets The amount includes:\n• service provider\'s fee\n• network fee for sending %s from the exchange back to the user\'s address. @@ -1557,17 +1583,6 @@ Explore transaction Service fees Fee - Replace card - Replace your card? - This generates a new set of card details. Your old details will stop working. You can\'t undo this. - Replacement fee - Replace card - Replacing your digital card - Usually takes up to 5 minutes. In rare cases, up to 48 hours. - Insufficient funds to replace the card - Unable to cover fee - Deposit USDC to payment account to cover the issuing fee - Replacement fee info unreachable Keep your money safe. You can unfreeze anytime. Freeze your card? Failed to freeze the card. Try again later. @@ -1629,6 +1644,7 @@ Share your address or show QR-code Technical issues detected. Please try again later or contact support. Receive unavailable now + Replace card Only letters and numbers are allowed. Invalid characters Reveal @@ -1643,14 +1659,18 @@ Withdraw unavailable now You can\'t initiate swap or new withdrawal till the current one is finished Withdrawal in progress - Card settings - Daily limit - Current limit Change - Daily limit unavailable + Current limit We couldn\'t load your daily limit. Please try again. + Daily limit unavailable + You can change it again anytime you like + Daily limit is set + Daily limit + Card settings Change PIN-code Come back to the app if you forget it. + Set a limit from %s to %s + Set limits Digital card I understand that I will completely lose access to my Tangem Pay Card and all funds on it without the possibility of recovery Failed to issue card @@ -1693,12 +1713,22 @@ Payment account Payment account is not synced Invalid PIN: avoid sequences or repeats + Replace card + This generates a new set of card details. Your old details will stop working. You can\'t undo this. + Replacement fee + Replacement fee info unreachable + Replacing your digital card + Usually takes up to 5 minutes. In rare cases, up to 48 hours. + Insufficient funds to replace the card + Deposit USDC to payment account to cover the issuing fee + Unable to cover fee + Replace your card? We’re fixing a technical issue. Please try again later. Service temporarily unavailable Unable to display details. However, card payments are still working. Set \nPIN code Session expired - Restore access + Renew session Use USDC for everyday payments Tangem Pay is temporarily unreachable Tangem Pay @@ -1786,7 +1816,7 @@ Use %s or scan a card/ring to have access to your wallet Connection failed: This dApp uses Wallet Connect version 1.0, which is not supported. Please ensure the dApp supports Wallet Connect version 2.0 to connect successfully. Previous permission will be revoked and a new one will be issued. Network will charge token approval fee for each of theses actions. You will see a zero-amount transaction in the history as a revoke evidence. - Transaction exceeds previously granted permission amount.\nUpdate permission to proceed + Transaction exceeds previously granted permission amount.\nUpdate permission to proceed Update permission Upgrade to hardware wallet Stay up to date with the latest features and news @@ -2157,8 +2187,6 @@ At least one network is required for dApp connection Specify selected networks Successfully signed - Share Addresses - Addresses to share WalletConnect To Transaction request @@ -2268,7 +2296,7 @@ All your future incoming %1$s deposits will be automatically supplied to Aave. Active Paused - Disabling Yield Mode + Disable Yield Mode Turning this off will withdraw your assets from Aave, convert them back to %s in your wallet, and stop yield accrual. A network fee is charged by the blockchain when you exit Yield Mode. Disable Yield Mode diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index 255027abc8..f5999ae461 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -1,6 +1,5 @@ package com.tangem.features.onboarding.v2.addresssync -import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.arkivanov.decompose.DelicateDecomposeApi @@ -18,6 +17,7 @@ import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncIntent import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncContent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.PushNotificationsComponent import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks import com.tangem.features.pushnotifications.api.PushNotificationsParams @@ -25,11 +25,12 @@ import com.tangem.features.pushnotifications.api.PushNotificationsParams @OptIn(DelicateDecomposeApi::class) internal class DefaultAddressSyncComponent( appComponentContext: AppComponentContext, + params: MultiWalletChildParams, private val askBiometryComponentFactory: AskBiometryComponent.Factory, private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, ) : AppComponentContext by appComponentContext, AddressSyncComponent { - private val model: AddressSyncModel = getOrCreateModel() + private val model: AddressSyncModel = getOrCreateModel(params) private val childStack: Value> = childStack( @@ -58,10 +59,6 @@ internal class DefaultAddressSyncComponent( } }, ) - - BackHandler { - model.onIntent(AddressSyncIntent.Back) - } } private fun createChild(step: AddressSyncStep, childContext: AppComponentContext): ComposableContentComponent { @@ -82,7 +79,6 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ASK_NOTIFICATIONS, - shouldReplace = true, ), ) } @@ -91,7 +87,6 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ASK_NOTIFICATIONS, - shouldReplace = false, ), ) } @@ -109,7 +104,6 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ADDRESS_SYNC, - shouldReplace = true, ), ) } @@ -118,7 +112,6 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ADDRESS_SYNC, - shouldReplace = false, ), ) } @@ -127,7 +120,6 @@ internal class DefaultAddressSyncComponent( model.onIntent( AddressSyncIntent.Next( step = AddressSyncStep.ADDRESS_SYNC, - shouldReplace = false, ), ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt index 1293c244d5..0776ea3cc7 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt @@ -3,6 +3,6 @@ package com.tangem.features.onboarding.v2.addresssync.model import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep sealed interface AddressSyncIntent { - data class Next(val step: AddressSyncStep, val shouldReplace: Boolean) : AddressSyncIntent + data class Next(val step: AddressSyncStep) : AddressSyncIntent data object Back : AddressSyncIntent } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt index b06e54736c..a73fa83fae 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -3,16 +3,19 @@ package com.tangem.features.onboarding.v2.addresssync.model import com.arkivanov.decompose.DelicateDecomposeApi import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.pop -import com.arkivanov.decompose.router.stack.push import com.arkivanov.decompose.router.stack.replaceCurrent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -23,10 +26,24 @@ internal class AddressSyncModel @Inject constructor( private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase, private val canUseBiometryUseCase: CanUseBiometryUseCase, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, + paramsContainer: ParamsContainer, ) : Model() { + private val params = paramsContainer.require() val stackNavigation = StackNavigation() + init { + params.innerNavigation.update { innerNavigationState -> + innerNavigationState.copy( + stackSize = AddressSyncStep.ASK_BIOMETRY.pageNumber, + stackMaxSize = ADDRESS_SYNC_MAX_STEPS, + ) + } + modelScope.launch { + trySkippingScreen(AddressSyncStep.ASK_BIOMETRY) + } + } + fun onIntent(intent: AddressSyncIntent) { when (intent) { is AddressSyncIntent.Next -> nextScreen(intent) @@ -35,27 +52,24 @@ internal class AddressSyncModel @Inject constructor( } private fun nextScreen(next: AddressSyncIntent.Next) { - val (nextStep, replace) = next - if (replace) { - stackNavigation.replaceCurrent(configuration = nextStep) - } else { - stackNavigation.push(configuration = nextStep) - } - modelScope.launch { trySkippingScreen(next) } + stackNavigation.replaceCurrent(configuration = next.step) + updateStepperPage(next) + updateTitle(next) + modelScope.launch { trySkippingScreen(next.step) } } - private suspend fun trySkippingScreen(next: AddressSyncIntent.Next) { - when (next.step) { + private suspend fun trySkippingScreen(step: AddressSyncStep) { + when (step) { AddressSyncStep.ASK_BIOMETRY -> { val shouldShowAskBiometry = canUseBiometryUseCase.strict() && shouldShowAskBiometryUseCase() if (shouldShowAskBiometry.not()) { - nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, shouldReplace = true)) + nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS)) } } AddressSyncStep.ASK_NOTIFICATIONS -> { val shouldShowAskNotification = shouldAskPermissionUseCase(PUSH_PERMISSION) if (shouldShowAskNotification.not()) { - nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ADDRESS_SYNC, shouldReplace = true)) + nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ADDRESS_SYNC)) } } AddressSyncStep.ADDRESS_SYNC -> Unit @@ -65,4 +79,22 @@ internal class AddressSyncModel @Inject constructor( private fun goBack() { stackNavigation.pop() } + + private fun updateStepperPage(next: AddressSyncIntent.Next) { + params.innerNavigation.update { innerNavigationState -> + innerNavigationState.copy( + stackSize = next.step.pageNumber, + ) + } + } + + private fun updateTitle(next: AddressSyncIntent.Next) { + params.parentParams.titleProvider.changeTitle( + text = resourceReference(next.step.stringId), + ) + } + + private companion object { + const val ADDRESS_SYNC_MAX_STEPS = 3 + } } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt index edd056ce90..c03a4fc662 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt @@ -1,7 +1,10 @@ package com.tangem.features.onboarding.v2.addresssync.navigation -enum class AddressSyncStep { - ASK_BIOMETRY, - ASK_NOTIFICATIONS, - ADDRESS_SYNC, +import androidx.annotation.StringRes +import com.tangem.features.onboarding.v2.impl.R + +enum class AddressSyncStep(val pageNumber: Int, @StringRes val stringId: Int) { + ASK_BIOMETRY(pageNumber = 1, stringId = R.string.onboarding_navbar_title_biometrics), + ASK_NOTIFICATIONS(pageNumber = 2, stringId = R.string.onboarding_title_notifications), + ADDRESS_SYNC(pageNumber = 3, stringId = R.string.onboarding_navbar_title_last_step), } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index 4fe733a371..4f7588e705 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -194,6 +194,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor ) AddressSync -> DefaultAddressSyncComponent( appComponentContext = childContext, + params = childParams, askBiometryComponentFactory = askBiometryComponentFactory, pushNotificationsComponentFactory = pushNotificationsComponentFactory, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt index cb903802ce..050a14f74e 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -1,5 +1,6 @@ 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.decompose.di.ModelScoped @@ -70,7 +71,11 @@ internal class OnboardingMultiWalletModel @Inject constructor( onConfirm = { modelScope.launch { onboardingRepository.clearUnfinishedFinalizeOnboarding() - router.pop() + if (params.mode == OnboardingMultiWalletComponent.Mode.AddressSync) { + router.replaceAll(AppRoute.Wallet) + } else { + router.pop() + } } }, ), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt index aff07ca039..5e4f6a5152 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/Utils.kt @@ -15,5 +15,5 @@ fun screenTitleByStep(step: OnboardingMultiWalletState.Step): TextReference = wh OnboardingMultiWalletState.Step.Finalize -> resourceReference(R.string.onboarding_button_finalize_backup) OnboardingMultiWalletState.Step.Done -> resourceReference(R.string.common_done) OnboardingMultiWalletState.Step.UpgradeWallet -> resourceReference(R.string.common_tangem) - OnboardingMultiWalletState.Step.AddressSync -> TODO("Will be implemented during [REDACTED_TASK_KEY]") + OnboardingMultiWalletState.Step.AddressSync -> resourceReference(R.string.onboarding_navbar_title_biometrics) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt index 0e4a8acc7d..f784c38c74 100644 --- a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -1,15 +1,23 @@ package com.tangem.features.onboarding.v2.addresssync.model import com.arkivanov.decompose.router.stack.StackNavigation +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.features.onboarding.v2.TitleProvider import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.onboarding.v2.multiwallet.impl.MultiWalletInnerNavigationState +import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -23,12 +31,36 @@ internal class AddressSyncModelTest { private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase = mockk() private val canUseBiometryUseCase: CanUseBiometryUseCase = mockk() private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase = mockk() + private val paramsContainer: ParamsContainer = mockk() + private val testInnerNavigation = MutableStateFlow( + value = MultiWalletInnerNavigationState( + stackSize = 0, + stackMaxSize = 0, + ) + ) + private val titleProvider: TitleProvider = mockk(relaxUnitFun = true) + private val params: MultiWalletChildParams = mockk { + every { innerNavigation } returns testInnerNavigation + every { parentParams } returns mockk { + every { titleProvider } returns this@AddressSyncModelTest.titleProvider + } + } @BeforeEach fun setUp() { coEvery { canUseBiometryUseCase.strict() } returns false coEvery { shouldShowAskBiometryUseCase() } returns false coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + every { paramsContainer.require() } returns params + } + + @Test + fun `WHEN model is created THEN inner navigation stack size and max size are set`() = runTest { + createModel(this) + + val state = testInnerNavigation.value + assert(state.stackSize == AddressSyncStep.ASK_BIOMETRY.pageNumber) + assert(state.stackMaxSize == AddressSyncStep.entries.size) } @Test @@ -39,10 +71,11 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, shouldReplace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ASK_BIOMETRY)) + assertStepperAndTitleFor(AddressSyncStep.ASK_BIOMETRY) } @Test @@ -55,10 +88,11 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, shouldReplace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS)) + assertStepperAndTitleFor(AddressSyncStep.ASK_NOTIFICATIONS) } @Test @@ -70,10 +104,11 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY, shouldReplace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC)) + assertStepperAndTitleFor(AddressSyncStep.ADDRESS_SYNC) } @Test @@ -83,10 +118,11 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, shouldReplace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS)) + assertStepperAndTitleFor(AddressSyncStep.ASK_NOTIFICATIONS) } @Test @@ -96,10 +132,16 @@ internal class AddressSyncModelTest { val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS, shouldReplace = false)) + model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS)) advanceUntilIdle() assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC)) + assertStepperAndTitleFor(AddressSyncStep.ADDRESS_SYNC) + } + + private fun assertStepperAndTitleFor(step: AddressSyncStep) { + assert(testInnerNavigation.value.stackSize == step.pageNumber) + verify { titleProvider.changeTitle(resourceReference(step.stringId)) } } private fun StackNavigation.trackStack(): List { @@ -118,6 +160,7 @@ internal class AddressSyncModelTest { shouldShowAskBiometryUseCase = shouldShowAskBiometryUseCase, canUseBiometryUseCase = canUseBiometryUseCase, shouldAskPermissionUseCase = shouldAskPermissionUseCase, + paramsContainer = paramsContainer, ) } From f43fb6a8ac87db74b445745497ea8020990e694b Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Apr 2026 11:55:20 +0400 Subject: [PATCH 095/206] Updated on 2026-08-14 --- .../tangem/tap/data/DefaultAppInfoProvider.kt | 19 +++--- .../tangem/tap/data/DefaultCardSdkProvider.kt | 7 +- .../java/com/tangem/tap/di/UtilsModule.kt | 6 ++ .../network/auth/DefaultAppVersionProvider.kt | 11 --- .../tangem/tap/network/auth/di/AuthModule.kt | 7 -- .../datasource/api/common/config/Express.kt | 5 +- .../api/common/config/GaslessTxService.kt | 4 +- .../datasource/api/common/config/News.kt | 4 +- .../datasource/api/common/config/TangemPay.kt | 14 ++-- .../api/common/config/TangemTech.kt | 4 +- .../api/common/config/YieldSupply.kt | 4 +- .../tangem/datasource/di/ApiConfigsModule.kt | 68 +++++++------------ .../com/tangem/datasource/di/AppInfoModule.kt | 21 ------ .../tangem/datasource/utils/RequestHeader.kt | 10 +-- .../api/common/config/ApiConfigTest.kt | 9 +-- .../managers/ProdApiConfigsManagerTest.kt | 26 +++---- .../com/tangem/utils/info/AppInfoProvider.kt | 33 +++++++++ .../utils/version/AppVersionProvider.kt | 8 --- .../feedback/DefaultFeedbackRepository.kt | 13 ++-- .../tangem/data/feedback/di/FeedbackModule.kt | 6 +- .../features/details/model/DetailsModel.kt | 6 +- .../ExcludedBlockchainsViewModel.kt | 6 +- .../viewmodels/FeatureTogglesViewModel.kt | 8 +-- 23 files changed, 122 insertions(+), 177 deletions(-) rename core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt => app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt (57%) delete mode 100644 app/src/main/java/com/tangem/tap/network/auth/DefaultAppVersionProvider.kt delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt delete mode 100644 core/utils/src/main/java/com/tangem/utils/version/AppVersionProvider.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt b/app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt similarity index 57% rename from core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt rename to app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt index d33089ff1b..69194e58e1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/info/AndroidAppInfoProvider.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultAppInfoProvider.kt @@ -1,26 +1,27 @@ -package com.tangem.datasource.info +package com.tangem.tap.data import android.os.Build import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider -import java.util.* +import com.tangem.wallet.BuildConfig +import java.util.Locale +import java.util.TimeZone import javax.inject.Inject -internal class AndroidAppInfoProvider @Inject constructor( - private val appVersionProvider: AppVersionProvider, -) : AppInfoProvider { +internal class DefaultAppInfoProvider @Inject constructor() : AppInfoProvider { override val platform: String get() = "Android" override val device: String get() = "${Build.MANUFACTURER} ${Build.MODEL}" override val osVersion: String get() = Build.VERSION.RELEASE + override val sdkVersion: Int + get() = Build.VERSION.SDK_INT override val language: String - get() = Locale.getDefault().language + get() = Locale.getDefault().toLanguageTag() override val timezone: String get() = TimeZone.getDefault().id - override val appVersion: String - get() = appVersionProvider.versionName + override val appVersion: String = BuildConfig.VERSION_NAME + override val appVersionCode: Int = BuildConfig.VERSION_CODE override val isHuaweiDevice: Boolean get() = Build.MANUFACTURER.equals("HUAWEI", ignoreCase = true) || Build.BRAND.equals("HUAWEI", ignoreCase = true) diff --git a/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt b/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt index 9918edc9f1..41ed151c3f 100644 --- a/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt +++ b/app/src/main/java/com/tangem/tap/data/DefaultCardSdkProvider.kt @@ -32,7 +32,6 @@ import com.tangem.tap.foregroundActivityObserver import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton @@ -48,7 +47,6 @@ internal class DefaultCardSdkProvider @Inject constructor( private val analyticsExceptionHandler: AnalyticsExceptionHandler, private val dispatchers: CoroutineDispatcherProvider, private val apiConfigsManager: ApiConfigsManager, - appVersionProvider: AppVersionProvider, appInfoProvider: AppInfoProvider, authProvider: AuthProvider, ) : CardSdkProvider, CardSdkOwner { @@ -74,10 +72,7 @@ internal class DefaultCardSdkProvider @Inject constructor( val apiEnvironment = Provider { apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemTech).environment } - val platformHeaders = RequestHeader.AppVersionPlatformHeaders( - appVersionProvider = appVersionProvider, - appInfoProvider = appInfoProvider, - ) + val platformHeaders = RequestHeader.AppVersionPlatformHeaders(appInfoProvider) val apiKeyHeader = RequestHeader.TangemApiKeyHeader(authProvider, apiEnvironment) TangemApiServiceSettings.addInterceptors( AddHeadersInterceptor(platformHeaders.values + apiKeyHeader.values), diff --git a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt index 0021307180..492904d18a 100644 --- a/app/src/main/java/com/tangem/tap/di/UtilsModule.kt +++ b/app/src/main/java/com/tangem/tap/di/UtilsModule.kt @@ -13,6 +13,8 @@ import com.tangem.tap.common.settings.IntentSettingsManager import com.tangem.tap.common.share.IntentShareManager import com.tangem.tap.common.url.CustomTabsUrlOpener import com.tangem.tap.core.DefaultAppCoroutineScope +import com.tangem.tap.data.DefaultAppInfoProvider +import com.tangem.utils.info.AppInfoProvider import dagger.Binds import dagger.Module import dagger.Provides @@ -28,6 +30,10 @@ internal interface UtilsModule { @Binds fun provideAppScope(defaultAppScope: DefaultAppCoroutineScope): AppCoroutineScope + @Binds + @Singleton + fun bindAppInfoProvider(impl: DefaultAppInfoProvider): AppInfoProvider + companion object { @Provides diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultAppVersionProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultAppVersionProvider.kt deleted file mode 100644 index a5093725e5..0000000000 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultAppVersionProvider.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.network.auth - -import com.tangem.utils.version.AppVersionProvider -import com.tangem.wallet.BuildConfig - -internal class DefaultAppVersionProvider : AppVersionProvider { - - override val versionName: String = BuildConfig.VERSION_NAME - - override val versionCode: Int = BuildConfig.VERSION_CODE -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt index 6ef9e06f96..6313d30db9 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/di/AuthModule.kt @@ -7,7 +7,6 @@ import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider import com.tangem.tap.network.auth.* -import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -47,10 +46,4 @@ internal class AuthModule { fun provideP2PEthPoolAuthProvider(environmentConfig: EnvironmentConfig): P2PEthPoolAuthProvider { return DefaultP2PEthPoolAuthProvider(environmentConfig) } - - @Provides - @Singleton - fun provideAppVersionProvider(): AppVersionProvider { - return DefaultAppVersionProvider() - } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt index 3cf8112a90..b0c61cbfc6 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Express.kt @@ -6,20 +6,17 @@ import com.tangem.datasource.utils.RequestHeader import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** * Express [ApiConfig] * * @property environmentConfig environment config * @property expressAuthProvider express auth provider - * @property appVersionProvider app version provider * @property appInfoProvider app info provider */ internal class Express( private val environmentConfig: EnvironmentConfig, private val expressAuthProvider: ExpressAuthProvider, - private val appVersionProvider: AppVersionProvider, private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { @@ -102,7 +99,7 @@ internal class Express( private fun createHeaders(isProd: Boolean) = buildMap { put(key = "api-key", value = ProviderSuspend { getApiKey(isProd) }) put(key = "session-id", value = ProviderSuspend(expressAuthProvider::getSessionId)) - putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) } private fun getApiKey(isProd: Boolean): String { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt index 35935f0c9d..111d593100 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt @@ -6,14 +6,12 @@ import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** * Gasless transactions [ApiConfig] */ internal class GaslessTxService( private val authProvider: AuthProvider, - private val appVersionProvider: AppVersionProvider, private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { @@ -50,7 +48,7 @@ internal class GaslessTxService( ) private fun createHeaders(environment: ApiEnvironment) = buildMap { - putAll(RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) put( key = "Authorization", value = ProviderSuspend { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt index 26b8b74472..8e4138d515 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/News.kt @@ -5,14 +5,12 @@ import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.Provider import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** * News [ApiConfig] [REDACTED_AUTHOR] */ internal class News( - private val appVersionProvider: AppVersionProvider, private val appInfoProvider: AppInfoProvider, private val authProvider: AuthProvider, ) : ApiConfig() { @@ -64,7 +62,7 @@ internal class News( apiEnvironment = Provider { environment }, ).values, ) - putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) } private companion object { diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt index f6c8752c2d..a7dd87e4db 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemPay.kt @@ -3,11 +3,11 @@ package com.tangem.datasource.api.common.config import com.tangem.datasource.BuildConfig import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.utils.ProviderSuspend -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider internal sealed class TangemPay( private val environmentConfig: EnvironmentConfig, - private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { override val defaultEnvironment: ApiEnvironment = getInitialEnvironment() @@ -52,7 +52,7 @@ internal sealed class TangemPay( ) private fun createHeaders(apiEnvironment: ApiEnvironment) = mapOf( - "version" to ProviderSuspend { appVersionProvider.versionName }, + "version" to ProviderSuspend { appInfoProvider.appVersion }, "platform" to ProviderSuspend { "Android" }, "X-API-KEY" to ProviderSuspend { getBffStaticToken(apiEnvironment) }, ) @@ -74,8 +74,8 @@ internal sealed class TangemPay( class Bff( environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, - ) : TangemPay(environmentConfig, appVersionProvider) { + appInfoProvider: AppInfoProvider, + ) : TangemPay(environmentConfig, appInfoProvider) { override fun getBaseUrl(apiEnvironment: ApiEnvironment): String { return when (apiEnvironment) { ApiEnvironment.DEV -> "https://api.dev.us.paera.com/bff-v2/" @@ -93,8 +93,8 @@ internal sealed class TangemPay( class Auth( environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, - ) : TangemPay(environmentConfig, appVersionProvider) { + appInfoProvider: AppInfoProvider, + ) : TangemPay(environmentConfig, appInfoProvider) { override fun getBaseUrl(apiEnvironment: ApiEnvironment): String { return when (apiEnvironment) { ApiEnvironment.DEV -> "https://api.dev.us.paera.com/" diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt index 96ef551d23..9500ec26e3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/TangemTech.kt @@ -5,11 +5,9 @@ import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.Provider import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** TangemTech [ApiConfig] */ internal class TangemTech( - private val appVersionProvider: AppVersionProvider, private val authProvider: AuthProvider, private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { @@ -62,7 +60,7 @@ internal class TangemTech( private fun createHeaders(apiEnvironment: ApiEnvironment) = buildMap { putAll(from = RequestHeader.TangemApiKeyHeader(authProvider, Provider { apiEnvironment }).values) - putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) putAll(from = RequestHeader.AuthenticationHeader(authProvider).values) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/YieldSupply.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/YieldSupply.kt index 4964259c7d..6b8f203c1d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/YieldSupply.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/YieldSupply.kt @@ -6,12 +6,10 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.utils.RequestHeader import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider /** YieldSupply [ApiConfig] */ internal class YieldSupply( private val environmentConfig: EnvironmentConfig, - private val appVersionProvider: AppVersionProvider, private val authProvider: AuthProvider, private val appInfoProvider: AppInfoProvider, ) : ApiConfig() { @@ -66,7 +64,7 @@ internal class YieldSupply( put(key = "api-key", value = ProviderSuspend { getApiKey(apiEnvironment) }) - putAll(from = RequestHeader.AppVersionPlatformHeaders(appVersionProvider, appInfoProvider).values) + putAll(from = RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) putAll(from = RequestHeader.AuthenticationHeader(authProvider).values) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt index e8db8ec219..e341d9724b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/ApiConfigsModule.kt @@ -7,7 +7,6 @@ import com.tangem.lib.auth.ExpressAuthProvider import com.tangem.lib.auth.P2PEthPoolAuthProvider import com.tangem.lib.auth.StakeKitAuthProvider import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,13 +22,11 @@ internal object ApiConfigsModule { fun provideExpressConfig( environmentConfig: EnvironmentConfig, expressAuthProvider: ExpressAuthProvider, - appVersionProvider: AppVersionProvider, appInfoProvider: AppInfoProvider, ): ApiConfig { return Express( environmentConfig = environmentConfig, expressAuthProvider = expressAuthProvider, - appVersionProvider = appVersionProvider, appInfoProvider = appInfoProvider, ) } @@ -48,55 +45,47 @@ internal object ApiConfigsModule { @Provides @IntoSet - fun provideTangemTechConfig( - appVersionProvider: AppVersionProvider, - authProvider: AuthProvider, - appInfoProvider: AppInfoProvider, - ): ApiConfig = TangemTech( - appVersionProvider = appVersionProvider, - authProvider = authProvider, - appInfoProvider = appInfoProvider, - ) + fun provideTangemTechConfig(authProvider: AuthProvider, appInfoProvider: AppInfoProvider): ApiConfig { + return TangemTech( + authProvider = authProvider, + appInfoProvider = appInfoProvider, + ) + } @Provides @IntoSet - fun provideNewsConfig( - appVersionProvider: AppVersionProvider, - authProvider: AuthProvider, - appInfoProvider: AppInfoProvider, - ): ApiConfig = News( - appVersionProvider = appVersionProvider, - appInfoProvider = appInfoProvider, - authProvider = authProvider, - ) + fun provideNewsConfig(authProvider: AuthProvider, appInfoProvider: AppInfoProvider): ApiConfig { + return News( + appInfoProvider = appInfoProvider, + authProvider = authProvider, + ) + } @Provides @IntoSet fun provideYieldSupplyConfig( environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, authProvider: AuthProvider, appInfoProvider: AppInfoProvider, - ): ApiConfig = YieldSupply( - environmentConfig = environmentConfig, - appVersionProvider = appVersionProvider, - authProvider = authProvider, - appInfoProvider = appInfoProvider, - ) + ): ApiConfig { + return YieldSupply( + environmentConfig = environmentConfig, + authProvider = authProvider, + appInfoProvider = appInfoProvider, + ) + } @Provides @IntoSet - fun provideTangemPayBffConfig( - environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, - ): ApiConfig = TangemPay.Bff(environmentConfig, appVersionProvider) + fun provideTangemPayBffConfig(environmentConfig: EnvironmentConfig, appInfoProvider: AppInfoProvider): ApiConfig { + return TangemPay.Bff(environmentConfig, appInfoProvider) + } @Provides @IntoSet - fun provideTangemPayAuthConfig( - environmentConfig: EnvironmentConfig, - appVersionProvider: AppVersionProvider, - ): ApiConfig = TangemPay.Auth(environmentConfig, appVersionProvider) + fun provideTangemPayAuthConfig(environmentConfig: EnvironmentConfig, appInfoProvider: AppInfoProvider): ApiConfig { + return TangemPay.Auth(environmentConfig, appInfoProvider) + } @Provides @IntoSet @@ -112,14 +101,9 @@ internal object ApiConfigsModule { @Provides @IntoSet - fun provideGaslessServiceConfig( - appVersionProvider: AppVersionProvider, - authProvider: AuthProvider, - appInfoProvider: AppInfoProvider, - ): ApiConfig { + fun provideGaslessServiceConfig(authProvider: AuthProvider, appInfoProvider: AppInfoProvider): ApiConfig { return GaslessTxService( authProvider = authProvider, - appVersionProvider = appVersionProvider, appInfoProvider = appInfoProvider, ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt deleted file mode 100644 index c67a5baa1f..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/di/AppInfoModule.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.datasource.di - -import com.tangem.datasource.info.AndroidAppInfoProvider -import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object AppInfoModule { - - @Singleton - @Provides - fun provideAppInfoProvider(appVersionProvider: AppVersionProvider): AppInfoProvider { - return AndroidAppInfoProvider(appVersionProvider) - } -} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt index ef2b83b6df..821a90cbba 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/RequestHeader.kt @@ -1,14 +1,11 @@ package com.tangem.datasource.utils -import android.os.Build import com.tangem.datasource.api.common.AuthProvider import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.utils.RequestHeader.CacheControlHeader.checkHeaderValueOrEmpty import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider -import java.util.Locale import java.util.TimeZone /** @@ -29,17 +26,16 @@ sealed class RequestHeader(vararg pairs: Pair>) ) class AppVersionPlatformHeaders( - appVersionProvider: AppVersionProvider, appInfoProvider: AppInfoProvider, ) : RequestHeader( "system_version" to ProviderSuspend { appInfoProvider.osVersion }, - "version" to ProviderSuspend { appVersionProvider.versionName }, + "version" to ProviderSuspend { appInfoProvider.appVersion }, "platform" to ProviderSuspend { "android" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { appInfoProvider.language.checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, - "device" to ProviderSuspend { "${Build.MANUFACTURER} ${Build.MODEL}".checkHeaderValueOrEmpty() }, + "device" to ProviderSuspend { appInfoProvider.device.checkHeaderValueOrEmpty() }, ) /** diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt index 1b1693d5a7..2e820ceaac 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/ApiConfigTest.kt @@ -51,21 +51,18 @@ class ApiConfigTest { Express( environmentConfig = environmentConfig, expressAuthProvider = mockk(), - appVersionProvider = mockk(), appInfoProvider = mockk(), ) } ApiConfig.ID.YieldSupply -> { YieldSupply( environmentConfig = environmentConfig, - appVersionProvider = mockk(), authProvider = appAuthProvider, appInfoProvider = mockk(), ) } ApiConfig.ID.TangemTech -> { TangemTech( - appVersionProvider = mockk(), authProvider = appAuthProvider, appInfoProvider = mockk(), ) @@ -73,23 +70,21 @@ class ApiConfigTest { ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = mockk()) ApiConfig.ID.TangemPay -> TangemPay.Bff( environmentConfig = environmentConfig, - appVersionProvider = mockk(), + appInfoProvider = mockk(), ) ApiConfig.ID.TangemPayAuth -> TangemPay.Auth( environmentConfig = environmentConfig, - appVersionProvider = mockk(), + appInfoProvider = mockk(), ) ApiConfig.ID.BlockAid -> BlockAid(environmentConfig = environmentConfig) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = mockk()) ApiConfig.ID.News -> News( - appVersionProvider = mockk(), authProvider = appAuthProvider, appInfoProvider = mockk(), ) ApiConfig.ID.GaslessTxService -> GaslessTxService( authProvider = appAuthProvider, - appVersionProvider = mockk(), appInfoProvider = mockk(), ) } diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index becd832cbc..102cfd0da8 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -19,7 +19,6 @@ import com.tangem.lib.auth.StakeKitAuthProvider import com.tangem.test.core.ProvideTestModels import com.tangem.utils.ProviderSuspend import com.tangem.utils.info.AppInfoProvider -import com.tangem.utils.version.AppVersionProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.every @@ -38,7 +37,6 @@ import java.util.TimeZone internal class ProdApiConfigsManagerTest { private val environmentConfig = createMockEnvironmentConfig() - private val appVersionProvider = mockk() private val expressAuthProvider = mockk() private val stakeKitAuthProvider = mockk() private val p2pEthPoolAuthProvider = mockk() @@ -52,14 +50,13 @@ internal class ProdApiConfigsManagerTest { @BeforeEach fun setup() { clearMocks( - appVersionProvider, expressAuthProvider, stakeKitAuthProvider, appAuthProvider, appInfoProvider, ) - every { appVersionProvider.versionName } returns VERSION_NAME + every { appInfoProvider.appVersion } returns VERSION_NAME every { expressAuthProvider.getSessionId() } returns EXPRESS_SESSION_ID every { stakeKitAuthProvider.getApiKey() } returns STAKE_KIT_API_KEY every { p2pEthPoolAuthProvider.getApiKey() } returns P2P_API_KEY @@ -71,6 +68,8 @@ internal class ProdApiConfigsManagerTest { coEvery { appAuthProvider.getCardPublicKey() } returns APP_CARD_PUBLIC_KEY every { appInfoProvider.osVersion } returns "Android 16" + every { appInfoProvider.language } returns Locale.getDefault().toLanguageTag() + every { appInfoProvider.device } returns "${Build.MANUFACTURER} ${Build.MODEL}" manager = ProdApiConfigsManager(apiConfigs = createApiConfigs()) } @@ -94,21 +93,18 @@ internal class ProdApiConfigsManagerTest { Express( environmentConfig = environmentConfig, expressAuthProvider = expressAuthProvider, - appVersionProvider = appVersionProvider, appInfoProvider = appInfoProvider, ) } ApiConfig.ID.YieldSupply -> { YieldSupply( environmentConfig = environmentConfig, - appVersionProvider = appVersionProvider, authProvider = appAuthProvider, appInfoProvider = appInfoProvider, ) } ApiConfig.ID.TangemTech -> { TangemTech( - appVersionProvider = appVersionProvider, authProvider = appAuthProvider, appInfoProvider = appInfoProvider, ) @@ -116,23 +112,21 @@ internal class ProdApiConfigsManagerTest { ApiConfig.ID.StakeKit -> StakeKit(stakeKitAuthProvider = stakeKitAuthProvider) ApiConfig.ID.TangemPay -> TangemPay.Bff( environmentConfig = environmentConfig, - appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, ) ApiConfig.ID.TangemPayAuth -> TangemPay.Auth( environmentConfig = environmentConfig, - appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, ) ApiConfig.ID.BlockAid -> BlockAid(environmentConfig = environmentConfig) ApiConfig.ID.MoonPay -> MoonPay() ApiConfig.ID.P2PEthPool -> P2PEthPool(p2pAuthProvider = p2pEthPoolAuthProvider) ApiConfig.ID.News -> News( - appVersionProvider = appVersionProvider, authProvider = appAuthProvider, appInfoProvider = appInfoProvider, ) ApiConfig.ID.GaslessTxService -> GaslessTxService( authProvider = appAuthProvider, - appVersionProvider = appVersionProvider, appInfoProvider = appInfoProvider, ) } @@ -195,7 +189,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "system_version" to ProviderSuspend { "Android 16" }, "platform" to ProviderSuspend { "android" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, @@ -218,7 +212,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "android" }, "system_version" to ProviderSuspend { "Android 16" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, @@ -241,7 +235,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "android" }, "system_version" to ProviderSuspend { "Android 16" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, @@ -316,7 +310,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "android" }, "system_version" to ProviderSuspend { "Android 16" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, @@ -394,7 +388,7 @@ internal class ProdApiConfigsManagerTest { "version" to ProviderSuspend { VERSION_NAME }, "platform" to ProviderSuspend { "android" }, "system_version" to ProviderSuspend { "Android 16" }, - "language" to ProviderSuspend { Locale.getDefault().language.checkHeaderValueOrEmpty() }, + "language" to ProviderSuspend { Locale.getDefault().toLanguageTag().checkHeaderValueOrEmpty() }, "timezone" to ProviderSuspend { TimeZone.getDefault().getDisplayName(false, TimeZone.SHORT).checkHeaderValueOrEmpty() }, diff --git a/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt b/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt index 7337a4f114..4ef568d2e6 100644 --- a/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt +++ b/core/utils/src/main/java/com/tangem/utils/info/AppInfoProvider.kt @@ -1,11 +1,44 @@ package com.tangem.utils.info +/** + * Runtime information about the host application and device. + * + * Consumed by API layers to populate request headers and bodies (see + * `RequestHeader.AppVersionPlatformHeaders` and push-notification registration) and by feature + * code that needs to branch on platform / vendor. + */ interface AppInfoProvider { + + /** Platform identifier, e.g. `"Android"`. */ val platform: String + + /** Human-readable device name in the form `"$MANUFACTURER $MODEL"`, e.g. `"Google Pixel 8"`. */ val device: String + + /** OS release string, e.g. `"14"` on Android 14. Corresponds to `Build.VERSION.RELEASE`. */ val osVersion: String + + /** + * Android API level of the running system, e.g. `34` on Android 14. Corresponds to + * `Build.VERSION.SDK_INT`. Use this (not [osVersion]) when branching by framework capability. + */ + val sdkVersion: Int + + /** Current locale as a BCP 47 language tag (e.g. `"en-US"`, `"zh-CN"`). */ val language: String + + /** IANA time-zone id of the device's current time zone, e.g. `"Europe/Moscow"`, `"UTC"`. */ val timezone: String + + /** User-visible app version string (e.g. `"5.36.4"`), matching `BuildConfig.VERSION_NAME`. */ val appVersion: String + + /** Monotonically-increasing internal build number, matching `BuildConfig.VERSION_CODE`. */ + val appVersionCode: Int + + /** + * `true` if the device manufacturer or brand is Huawei. Used to gate features that depend on + * Google Play Services (HMS-only devices cannot rely on FCM, Play Billing, etc.). + */ val isHuaweiDevice: Boolean } \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/version/AppVersionProvider.kt b/core/utils/src/main/java/com/tangem/utils/version/AppVersionProvider.kt deleted file mode 100644 index 23e26bebd1..0000000000 --- a/core/utils/src/main/java/com/tangem/utils/version/AppVersionProvider.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.utils.version - -interface AppVersionProvider { - - val versionName: String - - val versionCode: Int -} \ No newline at end of file diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt index 76ef2e52f0..511dcdc2e5 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackRepository.kt @@ -1,6 +1,5 @@ package com.tangem.data.feedback -import android.os.Build import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.core.navigation.email.EmailSender import com.tangem.data.feedback.converters.BlockchainInfoConverter @@ -14,8 +13,8 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.info.AppInfoProvider import com.tangem.utils.logging.TangemLogger -import com.tangem.utils.version.AppVersionProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import java.io.File @@ -27,7 +26,7 @@ import java.io.File * @property userWalletsListRepository repository for getting user wallets * @property walletManagersStore wallet managers store * @property emailSender email sender - * @property appVersionProvider app version provider + * @property appInfoProvider app info provider * [REDACTED_AUTHOR] */ @@ -36,7 +35,7 @@ internal class DefaultFeedbackRepository( private val userWalletsListRepository: UserWalletsListRepository, private val walletManagersStore: WalletManagersStore, private val emailSender: EmailSender, - private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, ) : FeedbackRepository { private val blockchainsErrors = MutableStateFlow>(emptyMap()) @@ -77,9 +76,9 @@ internal class DefaultFeedbackRepository( override fun getPhoneInfo(): PhoneInfo { return PhoneInfo( - phoneModel = Build.MODEL, - osVersion = Build.VERSION.SDK_INT.toString(), - appVersion = appVersionProvider.versionName, + phoneModel = appInfoProvider.device, + osVersion = appInfoProvider.sdkVersion.toString(), + appVersion = appInfoProvider.appVersion, ) } diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt index 6d4d9176c1..05133c89d1 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/di/FeedbackModule.kt @@ -9,7 +9,7 @@ import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.feedback.repository.FeedbackFeatureToggles import com.tangem.domain.feedback.repository.FeedbackRepository -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,13 +27,13 @@ internal object FeedbackModule { userWalletsListRepository: UserWalletsListRepository, walletManagersStore: WalletManagersStore, emailSender: EmailSender, - appVersionProvider: AppVersionProvider, + appInfoProvider: AppInfoProvider, ): FeedbackRepository { return DefaultFeedbackRepository( appLogsStore = appLogsStore, walletManagersStore = walletManagersStore, emailSender = emailSender, - appVersionProvider = appVersionProvider, + appInfoProvider = appInfoProvider, userWalletsListRepository = userWalletsListRepository, ) } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index e501fd3751..394aa04245 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -36,7 +36,7 @@ import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn @@ -54,7 +54,7 @@ internal class DetailsModel @Inject constructor( paramsContainer: ParamsContainer, feedbackFeatureToggles: FeedbackFeatureToggles, private val itemsBuilder: ItemsBuilder, - private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase, private val router: Router, private val urlOpener: UrlOpener, @@ -269,7 +269,7 @@ internal class DetailsModel @Inject constructor( } } - private fun getAppVersion(): String = "${appVersionProvider.versionName} (${appVersionProvider.versionCode})" + private fun getAppVersion(): String = "${appInfoProvider.appVersion} (${appInfoProvider.appVersionCode})" private companion object { val SYSTEM_LANGUAGE = runCatching { Resources.getSystem().configuration.locales[0].language }.getOrElse { "" } diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt index 3c2015978a..42f614a97d 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/excludedblockchains/ExcludedBlockchainsViewModel.kt @@ -13,7 +13,7 @@ import com.tangem.feature.tester.presentation.excludedblockchains.state.Blockcha import com.tangem.feature.tester.presentation.excludedblockchains.state.ExcludedBlockchainsScreenUM import com.tangem.feature.tester.presentation.excludedblockchains.state.mapper.toUiModels import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate @@ -25,7 +25,7 @@ import javax.inject.Inject @HiltViewModel internal class ExcludedBlockchainsViewModel @Inject constructor( - private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, excludedBlockchainsManager: ExcludedBlockchainsManager, ) : ViewModel() { @@ -56,7 +56,7 @@ internal class ExcludedBlockchainsViewModel @Inject constructor( search = getInitialSearchBar(), blockchains = getBlockchains(), showRecoverWarning = !excludedBlockchainsManager.isMatchLocalConfig(), - appVersion = appVersionProvider.versionName, + appVersion = appInfoProvider.appVersion, onRestartClick = {}, onRecoverClick = ::recoverLocalConfig, ) diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt index 95338c3250..130a27e2ff 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/featuretoggles/viewmodels/FeatureTogglesViewModel.kt @@ -13,7 +13,7 @@ import com.tangem.feature.tester.presentation.common.components.appbar.TopBarWit import com.tangem.feature.tester.presentation.featuretoggles.models.TesterFeatureToggle import com.tangem.feature.tester.presentation.featuretoggles.state.FeatureTogglesContentState import com.tangem.feature.tester.presentation.navigation.InnerTesterRouter -import com.tangem.utils.version.AppVersionProvider +import com.tangem.utils.info.AppInfoProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -25,14 +25,14 @@ import javax.inject.Inject * ViewModel for screen with list of feature toggles * * @property featureTogglesManager manager for getting information about the availability of feature toggles - * @property appVersionProvider app version provider + * @property appInfoProvider app info provider * [REDACTED_AUTHOR] */ @HiltViewModel internal class FeatureTogglesViewModel @Inject constructor( private val featureTogglesManager: FeatureTogglesManager, - private val appVersionProvider: AppVersionProvider, + private val appInfoProvider: AppInfoProvider, ) : ViewModel() { /** Current ui state */ @@ -55,7 +55,7 @@ internal class FeatureTogglesViewModel @Inject constructor( private fun initState(): FeatureTogglesContentState { return FeatureTogglesContentState( topBar = getConfigSetupState(isPrimarySetup = true), - appVersion = appVersionProvider.versionName, + appVersion = appInfoProvider.appVersion, featureToggles = mutableFeatureTogglesManager.getTesterFeatureToggles(), onToggleValueChange = ::onToggleValueChange, onRestartAppClick = {}, From 0e18cbe733eb6fd55a5d592992718fc69f314bb5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Apr 2026 17:07:48 +0400 Subject: [PATCH 096/206] Updated on 2026-08-14 --- .../java/com/tangem/tap/common/TestActions.kt | 13 ----- .../tangem/tap/common/extensions/Context.kt | 24 --------- .../com/tangem/tap/common/extensions/UI.kt | 45 ----------------- .../java/com/tangem/tap/domain/TapErrors.kt | 49 ------------------- .../java/com/tangem/tap/domain/TapSdkError.kt | 11 +++++ .../tap/domain/model/PendingTransaction.kt | 49 ------------------- .../tap/domain/model/WalletAddressData.kt | 10 ---- .../domain/scanCard/LegacyScanProcessor.kt | 8 +-- .../domain/scanCard/chains/DisclaimerChain.kt | 6 +-- .../tap/features/disclaimer/Disclaimer.kt | 29 ----------- .../disclaimer/DisclaimerDataProvider.kt | 11 ----- .../tap/features/disclaimer/DisclaimerType.kt | 25 ---------- .../intentHandler/AffectsNavigation.kt | 3 -- .../features/intentHandler/IntentHandler.kt | 11 ----- 14 files changed, 13 insertions(+), 281 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/common/TestActions.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/extensions/Context.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/TapErrors.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/TapSdkError.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/model/PendingTransaction.kt delete mode 100644 app/src/main/java/com/tangem/tap/domain/model/WalletAddressData.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/intentHandler/AffectsNavigation.kt delete mode 100644 app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt diff --git a/app/src/main/java/com/tangem/tap/common/TestActions.kt b/app/src/main/java/com/tangem/tap/common/TestActions.kt deleted file mode 100644 index 95edcd851e..0000000000 --- a/app/src/main/java/com/tangem/tap/common/TestActions.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.tap.common - -/** -[REDACTED_AUTHOR] - */ - -object TestActions { - - // It used only for the test actions in debug or debug_beta builds - var isTestAmountInjectionForWalletManagerEnabled = false -} - -typealias TestAction = Pair Unit> \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/Context.kt b/app/src/main/java/com/tangem/tap/common/extensions/Context.kt deleted file mode 100644 index 14f4cd0dbf..0000000000 --- a/app/src/main/java/com/tangem/tap/common/extensions/Context.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.tap.common.extensions - -import android.content.* -import android.content.pm.* -import android.content.res.* -import android.net.* -import androidx.annotation.* -import androidx.core.content.* - -/** - * Get uri to any resource type via given Resource Instance - * @param resId - resource id - * @throws Resources.NotFoundException if the given ID does not exist. - * @return - Uri to resource by the given ID - */ -@Throws(Resources.NotFoundException::class) -fun Context.resourceUri(@AnyRes resId: Int): Uri { - return Uri.parse( - ContentResolver.SCHEME_ANDROID_RESOURCE + - "://" + resources.getResourcePackageName(resId) + - '/' + resources.getResourceTypeName(resId) + - '/' + resources.getResourceEntryName(resId), - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/extensions/UI.kt b/app/src/main/java/com/tangem/tap/common/extensions/UI.kt index 355da51669..bc653053c7 100644 --- a/app/src/main/java/com/tangem/tap/common/extensions/UI.kt +++ b/app/src/main/java/com/tangem/tap/common/extensions/UI.kt @@ -1,56 +1,11 @@ -@file:Suppress("TooManyFunctions") - package com.tangem.tap.common.extensions import android.content.Context -import android.graphics.drawable.Drawable -import android.view.View import androidx.annotation.ColorInt import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.core.content.ContextCompat -fun Context.getDrawableCompat(@DrawableRes drawableResId: Int): Drawable? { - return ContextCompat.getDrawable(this, drawableResId) -} - @ColorInt fun Context.getColorCompat(@ColorRes colorRes: Int): Int { return ContextCompat.getColor(this, colorRes) -} - -@ColorInt -fun View.getColor(@ColorRes colorRes: Int): Int { - return ContextCompat.getColor(context, colorRes) -} - -fun View.getString(@StringRes id: Int): String { - return context.getString(id) -} - -fun View.getString(@StringRes id: Int, vararg formatArgs: String): String { - return context.getString(id, *formatArgs) -} - -fun View.show(show: Boolean, invokeBeforeStateChanged: (() -> Unit)? = null) { - return if (show) this.show(invokeBeforeStateChanged) else this.hide(invokeBeforeStateChanged) -} - -fun View.show(invokeBeforeStateChanged: (() -> Unit)? = null) { - if (this.visibility == View.VISIBLE) return - - invokeBeforeStateChanged?.invoke() - this.visibility = View.VISIBLE -} - -fun View.hide(invokeBeforeStateChanged: (() -> Unit)? = null) { - if (this.visibility == View.GONE) return - - invokeBeforeStateChanged?.invoke() - this.visibility = View.GONE -} - -fun View.getString(resId: Int, vararg formatArgs: Any?): String { - return context.getString(resId, *formatArgs) } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapErrors.kt b/app/src/main/java/com/tangem/tap/domain/TapErrors.kt deleted file mode 100644 index 2f1677fc5d..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/TapErrors.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.tap.domain - -import androidx.annotation.StringRes -import com.tangem.common.core.TangemError -import com.tangem.wallet.R - -interface TapErrors - -interface ArgError { - val args: List? -} - -interface MultiMessageError : TapErrors { - val errorList: List - val builder: (List) -> String -} - -sealed class TapError( - @StringRes val messageResource: Int, - override val args: List? = null, -) : Throwable(), TapErrors, ArgError { - - class UnknownError : TapError(R.string.send_error_unknown) - open class CustomError(val customMessage: String) : TapError(R.string.common_custom_string, listOf(customMessage)) - - class NoInternetConnection : TapError(R.string.wallet_notification_no_internet) - - sealed class WalletManager { - class NoAccountError(amountToCreateAccount: String) : CustomError(amountToCreateAccount) - class InternalError(message: String) : CustomError(message) - class BlockchainIsUnreachableTryLater : TapError(R.string.wallet_balance_blockchain_unreachable_try_later) - } -} - -sealed class TapSdkError(override val messageResId: Int?) : TangemError(code = 50100) { - override var customMessage: String = code.toString() - - class CardForDifferentApp : TapSdkError(R.string.alert_unsupported_card) - class CardNotSupportedByRelease : TapSdkError(R.string.error_wrong_card_type) -} - -fun TapErrors.assembleErrors(): MutableList?>> { - val idList = mutableListOf?>>() - when (this) { - is MultiMessageError -> this.errorList.forEach { idList.addAll(it.assembleErrors()) } - is TapError -> idList.add(Pair(this.messageResource, this.args)) - } - return idList -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/TapSdkError.kt b/app/src/main/java/com/tangem/tap/domain/TapSdkError.kt new file mode 100644 index 0000000000..9ce9469d75 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/TapSdkError.kt @@ -0,0 +1,11 @@ +package com.tangem.tap.domain + +import com.tangem.common.core.TangemError +import com.tangem.wallet.R + +sealed class TapSdkError(override val messageResId: Int?) : TangemError(code = 50100) { + override var customMessage: String = code.toString() + + class CardForDifferentApp : TapSdkError(R.string.alert_unsupported_card) + class CardNotSupportedByRelease : TapSdkError(R.string.error_wrong_card_type) +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/model/PendingTransaction.kt b/app/src/main/java/com/tangem/tap/domain/model/PendingTransaction.kt deleted file mode 100644 index 8548b11eba..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/model/PendingTransaction.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.tap.domain.model - -import com.tangem.blockchain.common.TransactionData -import com.tangem.blockchain.common.TransactionStatus -import com.tangem.blockchain.common.Wallet - -data class PendingTransaction( - val transactionData: TransactionData.Uncompiled, - val type: PendingTransactionType, -) { - val address: String? = when (type) { - PendingTransactionType.Incoming -> nullIfUnknown(transactionData.sourceAddress) - PendingTransactionType.Outgoing -> nullIfUnknown(transactionData.destinationAddress) - PendingTransactionType.Unknown -> null - } - - val currency: String = transactionData.amount.currencySymbol - - private fun nullIfUnknown(address: String): String? = if (address == "unknown") null else address -} - -enum class PendingTransactionType { Incoming, Outgoing, Unknown } - -fun TransactionData.Uncompiled.toPendingTransaction(walletAddress: String): PendingTransaction? { - if (this.status == TransactionStatus.Confirmed) return null - - val type: PendingTransactionType = when { - this.sourceAddress == walletAddress -> PendingTransactionType.Outgoing - this.destinationAddress == walletAddress -> PendingTransactionType.Incoming - else -> PendingTransactionType.Unknown - } - return PendingTransaction(this, type) -} - -fun List.toPendingTransactions(walletAddress: String): List { - return this.mapNotNull { it.toPendingTransaction(walletAddress) } -} - -fun Wallet.getPendingTransactions(type: PendingTransactionType? = null): List { - val txs = recentTransactions.toPendingTransactions(address) - return when (type) { - null -> txs - else -> txs.filter { it.type == type } - } -} - -fun Wallet.hasPendingTransactions(): Boolean { - return getPendingTransactions().isNotEmpty() -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/model/WalletAddressData.kt b/app/src/main/java/com/tangem/tap/domain/model/WalletAddressData.kt deleted file mode 100644 index a24fe98346..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/model/WalletAddressData.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.tap.domain.model - -import com.tangem.blockchain.common.address.AddressType - -internal data class WalletAddressData( - val address: String, - val type: AddressType, - val shareUrl: String, - val exploreUrl: String, -) \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index 10dafea284..74d2e3d21f 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -30,7 +30,6 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.sdk.extensions.localizedDescriptionRes import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor -import com.tangem.tap.features.disclaimer.createDisclaimer import com.tangem.tap.features.onboarding.OnboardingHelper import com.tangem.tap.mainScope import com.tangem.tap.scope @@ -145,16 +144,12 @@ internal class LegacyScanProcessor @Inject constructor( Analytics.send(analyticsEvent) } - // TODO: [REDACTED_JIRA] - @Suppress("UnusedPrivateMember") private suspend inline fun showDisclaimerIfNeed( scanResponse: ScanResponse, crossinline disclaimerWillShow: () -> Unit = {}, crossinline nextHandler: suspend (ScanResponse) -> Unit, ) { - val disclaimer = scanResponse.card.createDisclaimer(cardRepository) - - if (disclaimer.isAccepted()) { + if (cardRepository.isTangemTOSAccepted()) { nextHandler(scanResponse) } else { scope.launch { @@ -206,7 +201,6 @@ internal class LegacyScanProcessor @Inject constructor( } } - @Suppress("LongMethod", "LongParameterList", "MagicNumber") private suspend inline fun onScanSuccess( scanResponse: ScanResponse, crossinline onProgressStateChange: suspend (showProgress: Boolean) -> Unit, diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/DisclaimerChain.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/DisclaimerChain.kt index 93a1f9feac..508b5d0255 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/DisclaimerChain.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/DisclaimerChain.kt @@ -8,7 +8,6 @@ import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.core.chain.Chain import com.tangem.domain.core.chain.ResultChain import com.tangem.domain.models.scan.ScanResponse -import com.tangem.tap.features.disclaimer.createDisclaimer /** * Handles disclaimer display after the card scanning operation. @@ -25,14 +24,11 @@ internal class DisclaimerChain( ) : ResultChain() { override suspend fun launch(previousChainResult: ScanResponse): ScanChainResult { - val disclaimer = previousChainResult.card.createDisclaimer(cardRepository) - - return if (disclaimer.isAccepted()) { + return if (cardRepository.isTangemTOSAccepted()) { previousChainResult.right() } else { disclaimerWillShow() - // TODO: [REDACTED_JIRA] appRouter.push(route = AppRoute.Disclaimer(isTosAccepted = false)) previousChainResult.right() diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt deleted file mode 100644 index 3524b38f40..0000000000 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/Disclaimer.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.tangem.tap.features.disclaimer - -import android.net.Uri - -/** -[REDACTED_AUTHOR] - */ -interface Disclaimer { - fun getUri(): Uri - suspend fun accept() - suspend fun isAccepted(): Boolean -} - -abstract class BaseDisclaimer( - private val dataProvider: DisclaimerDataProvider, -) : Disclaimer { - - val baseUrl = "https://tangem.com" - - override suspend fun accept() { - dataProvider.accept() - } - - override suspend fun isAccepted(): Boolean = dataProvider.isAccepted() -} - -class TangemDisclaimer(dataProvider: DisclaimerDataProvider) : BaseDisclaimer(dataProvider) { - override fun getUri(): Uri = Uri.parse("$baseUrl/tangem_tos.html") -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt deleted file mode 100644 index ba3c82563d..0000000000 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerDataProvider.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.features.disclaimer - -/** -[REDACTED_AUTHOR] - */ -interface DisclaimerDataProvider { - fun getLanguage(): String - fun getCardId(): String - suspend fun accept() - suspend fun isAccepted(): Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt b/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt deleted file mode 100644 index 2f44bf9dd0..0000000000 --- a/app/src/main/java/com/tangem/tap/features/disclaimer/DisclaimerType.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.tap.features.disclaimer - -import com.tangem.domain.card.repository.CardRepository -import com.tangem.domain.models.scan.CardDTO -import java.util.Locale - -fun CardDTO.createDisclaimer(cardRepository: CardRepository): Disclaimer { - val dataProvider = provideDisclaimerDataProvider(cardId, cardRepository) - return TangemDisclaimer(dataProvider) -} - -private fun provideDisclaimerDataProvider(cardId: String, cardRepository: CardRepository): DisclaimerDataProvider { - return object : DisclaimerDataProvider { - override fun getLanguage(): String = Locale.getDefault().language - override fun getCardId(): String = cardId - - override suspend fun accept() { - cardRepository.acceptTangemTOS() - } - - override suspend fun isAccepted(): Boolean { - return cardRepository.isTangemTOSAccepted() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/AffectsNavigation.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/AffectsNavigation.kt deleted file mode 100644 index e6d38c381c..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/AffectsNavigation.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.tangem.tap.features.intentHandler - -interface AffectsNavigation \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt b/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt deleted file mode 100644 index 7458f759ba..0000000000 --- a/app/src/main/java/com/tangem/tap/features/intentHandler/IntentHandler.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tangem.tap.features.intentHandler - -import android.content.Intent - -/** -[REDACTED_AUTHOR] - */ -interface IntentHandler { - - fun handleIntent(intent: Intent?): Boolean -} \ No newline at end of file From e1c819d421186c43884f2573ceea4a70992e02f5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Apr 2026 09:28:35 +0100 Subject: [PATCH 097/206] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 39 + core/res/src/main/res/values-es/strings.xml | 8 + core/res/src/main/res/values-fr/strings.xml | 8 + core/res/src/main/res/values-ja/strings.xml | 76 +- .../src/main/res/values-pt-rBR/strings.xml | 28 +- core/res/src/main/res/values-ru/strings.xml | 80 +- .../src/main/res/values-uk-rUA/strings.xml | 12 + .../src/main/res/values-zh-rCN/strings.xml | 1650 +++++++++++++++++ core/res/src/main/res/values/strings.xml | 11 + 9 files changed, 1905 insertions(+), 7 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 6582af4d32..de1e4a59f4 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -80,6 +80,10 @@ Token hinzufügen Wähle den Token aus, den Du erhalten möchtest Wähle den Token, den Du tauschen möchtest + Zum Portfolio hinzufügen + Token hinzufügen + Sortieren und Gruppieren + Token verwalten Netzwerk wählen Token anlegen Token verwalten @@ -304,6 +308,10 @@ Ausblenden Halten bis %s Stunde + + %dStunde her + %dStunden her + Importieren In Arbeit Unzureichende Mittel @@ -314,6 +322,10 @@ Gesperrt Gesperrte Wallet Hauptnetz + + %dMinute her + %dMinuten her + Monat Netzgebühr Der überwiesene Betrag wird um %1$s (%2$s) gekürzt, um die gewählte Gebührenhöhe zu decken. @@ -477,6 +489,11 @@ Sende Geld nur mit Dynamische Adressen Dynamische Adressen ist aktiviert. Die benutzerdefinierten Funktionen \"Ändern\" und \"Index\" sind nicht verfügbar. + Sie deaktivieren dynamische Adressen. Zahlungen werden nun an Ihre feste Adresse gesendet. + Für die Zusammenführung von Geldern auf der Festadresse wird eine Netzwerkgebühr erhoben. + Dynamische Adressen deaktivieren + Dynamische Adressen deaktivieren + Dynamische Adressen deaktiviert Einige Adressen fehlen Verwenden Sie für jede Transaktion eine neue Adresse, um die Rückverfolgbarkeit zu verringern und den Datenschutz in der Kette zu verbessern. Verbesserter Datenschutz @@ -559,6 +576,7 @@ Anbieter Bester Preis Warnliste der FCA + Der Festzins ist nicht verfügbar Beste Wahl Anbieter in FCA-Warnliste Verfügbar bis zu %s @@ -584,6 +602,8 @@ Wähle aus, mit welchem Token Du die Netzgebühr bezahlen möchtest. %s Token auswählen Markt & Nachrichten + Alle anzeigen + Weniger anzeigen Aktuelle Trends Die folgenden Angaben sind freiwillig. Du kannst diese löschen, wenn du sie nicht weitergeben möchtest. Teile uns mit, welche Funktionen du vermisst, und wir werden versuchen, dir zu helfen. @@ -719,6 +739,7 @@ Mana-Limit Das Koinos-Netzwerk benötigt Mana als Netzwerkgebühr. Du hast %1$s/%2$s Mana Mana-Level + Hinzufügen und Verwalten Um mit der Verfolgung deiner Krypto-Assets und -Transaktionen zu beginnen, füge einen Token hinzu Token verwalten QR-Code scannen, um Geld zu senden oder eine Verbindung zu einer App herzustellen @@ -1534,6 +1555,7 @@ Handel zu groß Wir freuen uns über Ihr Feedback Tangem Pay jetzt in der Beta + Karte kann nicht umbenannt werden Karte eingefroren Kartenzahlung Einzahlung @@ -1567,7 +1589,9 @@ Ihre Karte ist entsperrt. Abhebung Auf gerooteten Geräten nicht nutzbar. + Verfügbares Guthaben KYC vom Hauptbildschirm ausblenden + Tangem Pay Karte 1 Guthaben hinzufügen Aufladeoptionen Zu Google Wallet hinzufügen @@ -1600,10 +1624,13 @@ Teile Deine Adresse mit oder zeig den QR-Code. Es wurden technische Probleme festgestellt. Bitte versuche es später erneut oder kontaktiere den Support. Empfangen ist jetzt nicht verfügbar + Es sind nur Buchstaben und Zahlen erlaubt. + Ungültige Zeichen Aufdecken Details anzeigen Tausche beliebige Vermögenswerte in Deinem Portfolio für deine Karte. Kartendetails + Bitte versuchen Sie es später noch einmal. Karte entsperren Komm zurück zur App, falls du es vergisst. Dein PIN-Code @@ -1611,12 +1638,16 @@ Auszahlung derzeit nicht möglich Sie können keinen Tausch oder eine neue Auszahlung starten, bis die aktuelle abgeschlossen ist. Auszahlung läuft + Einstellungen der Karte PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. + Digitale Karte Mir ist bewusst, dass ich den Zugriff auf meine Tangem Pay Card und alle darauf befindlichen Guthaben vollständig und ohne Möglichkeit der Wiederherstellung verliere. Kartenausstellung fehlgeschlagen Ein technischer Fehler ist aufgetreten, bitte versuchen Sie es erneut, indem Sie auf die Schaltfläche unten klicken Ein technischer Fehler ist aufgetreten, bitte kontaktieren Sie den Support + Die Funktion wird in Kürze verfügbar sein. + Sie können zusätzliche Karten für Ihr Zahlungskonto ausstellen Holen Sie sich Ihre kostenlose virtuelle Tangem Visa-Karte Tangem Pay erhalten Zum Support @@ -1648,6 +1679,7 @@ Ein separates Zahlungskonto wird erstellt, ohne Ihre Adressen und Vermögenswerte offenzulegen Unerreichte Privatsphäre Erhalten Sie Ihre kostenlose Tangem Pay Card in wenigen Minuten + Bezahlen mit Zahlungskonto Zahlungskonto ist nicht synchronisiert Ungültige PIN: Sequenzen oder Wiederholungen vermeiden @@ -1660,6 +1692,7 @@ Nutzen Sie USDC für alltägliche Zahlungen Tangem Pay ist vorübergehend nicht erreichbar. Tangem Pay + USDC im Polygon-Netzwerk Klicken Sie auf die Schaltfläche unten, um den Zugriff wiederherzustellen Ihr USDC Polygon On-Chain-Guthaben unterscheidet sich von Ihrem Kartenguthaben und wird innerhalb von 2 Werktagen nach einem Kauf aktualisiert. Gelder aus erstatteten Käufen werden nicht auf Ihr On-Chain-Guthaben zurückerstattet und stehen nicht für Abhebungen zur Verfügung, bleiben aber auf Ihrem Kartenguthaben für Einkäufe verfügbar. Bitte beachten Sie @@ -1681,6 +1714,8 @@ Der Verkauf von %s wird von aktuellen Anbietern nicht unterstützt, aber wir arbeiten daran, weitere Optionen hinzuzufügen. Staking %s wird von aktuellen Anbietern nicht unterstützt, aber wir arbeiten daran, weitere Optionen hinzuzufügen. Die Genehmigung wurde widerrufen. Dein Guthaben befindet sich weiterhin im Ertragsmodus. Um Aktionen durchzuführen, wechsel bitte in den Ertragsmodus und erteilen die Berechtigung erneut. + Verfügbares Guthaben + Gesamtsaldo Generiere XPUB Ausblenden Du bist dabei, dieses Token vom Hauptbildschirm auszublenden. Du kannst es jederzeit über die Seite „Token verwalten“ wieder hinzufügen. @@ -1690,6 +1725,10 @@ Staking-Dienst %1$s Token in %%image%% %2$s Netzwerk Token in %%image%% %1$s Netzwerk + %s Netzwerk + %1$s In %2$s Netzwerk + %1$s in %%Bild%% %2$s + %1$s in %2$s %%Bild%% Der %1$s (%2$s) Token ist die Hauptwährung im %3$s Netzwerk und kann nicht versteckt werden, solange du andere Token dieses Netzwerks in der Liste aktiv hast. %s kann nicht ausgeblendet werden N / A diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 970e3f83af..1344cbb70a 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -304,6 +304,10 @@ Ocultar Mantener para %s hora + + Hace %dh + Hace %dh + Importe En progreso Fondos insuficientes @@ -314,6 +318,10 @@ Bloqueado Billeteras bloqueadas Red principal + + Hace %d minuto + Hace %d minutos + mes Tarifa de la red La cantidad enviada se reducirá en %1$s (%2$s) para cubrir el nivel de tarifa seleccionado diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 5cfb59bdbc..86ff4ac437 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -304,6 +304,10 @@ Cacher Maintenir contre %s heure + + Il y a %dh + Il y a %dh + Importez En cours Plus tard @@ -313,6 +317,10 @@ Verrouillé Portefeuilles verrouillés Réseau principal + + Il y a %d minute + Il y a %d minutes + mois Commissions du réseau Le montant envoyé sera réduit de %1$s(%2$s) pour couvrir le niveau de frais sélectionné diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index c2dfddaf6f..2797beb6c3 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -80,6 +80,10 @@ トークンの追加 受け取りたいトークンを選択します スワップしたいトークンを選択します + ポートフォリオに追加 + トークンを追加 + 並べ替え・グループ化 + トークンを整理 ネットワークを選択 カスタムトークンの追加 トークンの管理 @@ -299,6 +303,9 @@ 非表示 %sまで長押し 時間 + + %d時間前 + インポート 進行中 残高不足 @@ -309,6 +316,9 @@ ロックされています ロックされたウォレット メインネットワーク + + %d分前 + ネットワーク手数料 送金額は、選択された手数料レベルをカバーするため、%1$s (%2$s) 減額されます。 @@ -470,6 +480,11 @@ 下記のみを使用して資金を送金する 動的アドレス 動的アドレスが有効になっています。カスタムの「change」と「index」は利用できません。 + 動的アドレスをオフにすると、資金は固定アドレスで受け取るようになります。 + 資金を固定アドレスに集約するには、ネットワーク手数料がかかります。 + 動的アドレスを無効にする + 動的アドレスを無効にする + 動的アドレスは無効です 動的アドレスが有効です。 取引ごとに新しいアドレスを使うことで、追跡されにくくなり、オンチェーン上のプライバシーが向上します。 プライバシー強化 @@ -481,6 +496,9 @@ 動的アドレスは利用できません 現在、プロバイダーに接続できません。しばらくしてからもう一度お試しください。 サービスを利用できません。しばらくしてからもう一度お試しください。 + 追加のアドレスに資金が見つかりました。アクセスするには、動的アドレスを有効にしてください。 + 追加のアドレスで資金が見つかりました + 動的アドレス おすすめ 絞り込みを解除 リストは現在更新中のため、一時的に空になっています。しばらくしてからご確認ください。 @@ -552,6 +570,7 @@ プロバイダー ベストレート FCA警告リスト + 固定レートは利用できません お得なレート FCA警告リストに掲載されたプロバイダー 最大 %s まで使用可能 @@ -577,6 +596,8 @@ \nネットワーク手数料の支払いに使用するトークンを選択します。%s トークンを選択 マーケット&ニュース + すべて表示 + 表示を減らす Tangem AI いま話題 以下の情報はオプションです。共有したくない場合は消去できます。 @@ -713,6 +734,7 @@ Mana上限 Koinosネットワークでは、ネットワーク手数料としてManaが必要です。あなたは%1$s / %2$sManaを持っています。 Manaレベル + 追加・管理 暗号資産および取引の追跡を開始するには、トークンを追加してください トークンの管理 QRコードをスキャンして送金するか、アプリに接続します。 @@ -747,6 +769,7 @@ このアセットはこのウォレットでは使用できません。 追加 APY %s + 市場価格 私のポートフォリオ マーケット ステーキング・利息モード @@ -759,6 +782,7 @@ データなし **ポートフォリオに追加して**、この資産の買付・交換・受け取りを始めましょう ポートフォリオ内 + ポートフォリオ マーケット動向 クイックアクション すべてクリア @@ -992,7 +1016,9 @@ スタート 追加しようとしているカードには、すでに別のウォレットが作成されています。このウォレットに資金がある場合は、それを引き出してからこのカードをリセットし、バックアップとして追加してください。 ウォレットを保存する + 生体認証を使用する バックアップの作成 + 最終ステップ 生体認証 シードフレーズについてもっと読む @@ -1482,11 +1508,16 @@ いつもここに 複数の信頼できるプロバイダーが一箇所に集結。ウォレット内で様々な暗号資産を簡単に交換できます。 ぜひスワップしてください + ウォレット内でスワップ 失敗も死角もありません。取引は常に保護されます。 + スワップは信頼できるプロバイダーを通じて実行されます。秘密鍵は常にTangemウォレット内に保持されます。シンプル。透明。自己管理。 難攻不落の防御 + 主導権はあなたの手にあります 幅広いネットワークの中から、常に最適なプロバイダーと料金レートを選択します 破格のレート + 利用可能なベストレート 手間がかからず直感的に操作でき、数回タップするだけでトークンを交換できます。 + 主要ネットワークと数千種類のトークンに対応 ステーブルコイン同士のスワップは手数料0% とにかく便利 プロバイダー経由のスワップ 資産 @@ -1514,6 +1545,7 @@ 取引額が大きすぎます 皆様からのフィードバックをお待ちしております Tangem Payのベータ版を公開しました + カード名を変更できません カードが凍結されています カード決済 入金 @@ -1547,7 +1579,9 @@ カードの凍結が解除されました 出金 Root化された端末では使用できません + 利用可能残高 メイン画面からKYCを非表示にする + Tangem Payカード 1 資金を追加 入金オプション Googleウォレットに追加 @@ -1580,10 +1614,14 @@ アドレスを共有するか、QRコードを表示してください。 技術的な問題が検出されました。しばらくしてからもう一度お試しいただくか、サポートにお問い合わせください。 現在、受け取りは利用できません + カードを交換する + 英字と数字のみ使用できます + 無効な文字が含まれています 表示 詳細を表示 ポートフォリオ内のあらゆる資産をカードと交換 カードの詳細 + しばらくしてからもう一度お試しください カードの一時停止を解除 忘れた場合は、アプリに戻ってください。 PINコード @@ -1591,12 +1629,25 @@ 現在、出金ができません 現在の処理が完了するまでは、スワップや新しい出金を開始することはできません。 出金処理中です + 変更 + 現在の利用限度額 + 1日の利用限度額を読み込めませんでした。もう一度お試しください。 + 1日の利用限度額を表示できません + いつでも再度変更できます + 1日の上限を設定しました + 1日の利用限度額 + カード設定 PINコードを変更 忘れた場合はアプリに戻って確認できます。 + %s 〜 %sの範囲で上限を設定 + 上限を設定 + デジタルカード 復元は不可能であり、Tangem Payカードおよびカード上のすべての資金へのアクセスを完全に失うことを理解しました カードの発行に失敗しました 技術的なエラーが発生しました。下のボタンをクリックして、もう一度お試しください。 技術的なエラーが発生しました。サポートへお問い合わせください。 + まもなくご利用いただけるようになります + 支払いアカウントで追加カードを発行できるようになります。 無料のTangem Visaバーチャルカードを入手 Tangem Payを入手 サポートへ移動 @@ -1628,18 +1679,30 @@ あなたの住所や資産を開示することなく、個別の支払い用アカウントが作成されます。 他に類を見ないプライバシー 無料のTangem Payカードを数分でゲットしましょう + Payサポート 支払いアカウント 支払アカウントが同期されていません 無効な暗証番号:連続や繰り返しを避けてください + カードを交換 + これにより、新しいカード情報が発行されます。現在のカード情報は使えなくなります。この操作は元に戻せません。 + 交換手数料 + 交換手数料の情報にアクセスできません + デジタルカードを交換しています + 通常は5分以内に完了します。まれに最大48時間かかる場合があります。 + カード交換に必要な残高が不足しています + 発行手数料を支払うため、決済口座にUSDCを入金してください + 手数料を支払えません + カードを交換しますか? 技術的な問題を修正しています。後でもう一度お試しください。 サービスは一時的に利用できません 現在、データを表示できませんが、カードでのお支払いは引き続きご利用いただけます。 \nPINコードの設定 セッションの有効期限が切れました - アクセスを復元 + セッションを更新 日常の支払いにUSDCを利用 Tangem Payは現在一時的に利用できません。 Tangem Pay + Polygonネットワーク上のUSDC 下のボタンをクリックしてアクセスを復元してください USDC Polygonのオンチェーン残高はカード残高とは異なり、購入後2営業日以内に更新されます。返金された購入資金はオンチェーン残高に戻らず、出金もできませんが、カード残高に残り、購入に使用できます。 ご注意ください @@ -1661,6 +1724,9 @@ %sの売却は、現在のプロバイダーではサポートされていませんが、より多くのオプションを追加できるよう取り組んでいます。 %sのステーキングは、現在のプロバイダーではサポートされていませんが、より多くのオプションを追加できるよう取り組んでいます。 承認が取り消されましたが、あなたの資金は引き続き利息モードです。操作を行うには、利息モードに移動し、再度承認を付与してください。 + 利用可能残高 + 合計残高 + 年間で最大%sを獲得 XPUBを生成する 非表示 このトークンをメイン画面から非表示にします。トークンの管理ページからいつでも再度追加できます。 @@ -1670,6 +1736,10 @@ ステーキングサービス %%image%% %2$s ネットワークの %1$s トークン %%image%% %1$sネットワーク上のトークン + %s ネットワーク + %2$s ネットワークの%1$s + %%image%% %2$sの%1$s + %2$sの%1$s %%image%% %1$s ( %2$s ) トークンは%3$sネットワークの主要通貨であり、このネットワーク上の他のトークンがリストにある限り、非表示にすることはできません。 %sを非表示にできません 該当なし @@ -1716,7 +1786,7 @@ %sを使用するか、カード / リングをスキャンしてウォレットにアクセスしてください 接続に失敗しました:このdAppは、サポートされていないWallet Connectバージョン1.0を使用しています。正常に接続するには、dAppがWallet Connectバージョン2.0をサポートしていることを確認してください。 以前の承認は取り消され、新しい承認が発行されます。これらの各操作には、ネットワークによるトークン承認手数料がかかります。履歴には、取り消しの証拠として金額0の取引が表示されます。 - 取引金額が、以前に許可された承認額を超えています。\n続行するには、承認内容を更新してください。 + 取引金額が、以前に許可された承認額を超えています。\n続行するには、承認内容を更新してください 承認を更新 ハードウェアウォレットにアップグレード 最新の機能とニュースをお届けします @@ -2193,7 +2263,7 @@ 今後のすべての %1$s 入金は自動的に Aave に供給されます。 アクティブ 停止中 - 利回りモードを解除中 + 利息モードを無効にする これをオフにすると、Aaveから資産が引き出され、ウォレット内の%sに変換され、利回りの発生が停止します。 利息モードを終了すると、ブロックチェーンによってネットワーク料金が請求されます。 利息モードを無効にする diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index e011607a12..d382f6e81a 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -308,6 +308,10 @@ Esconder Mantenha-se em %s hora + + UM + OUTRO + Importar Em andamento Saldo insuficiente @@ -318,6 +322,10 @@ Bloqueado Carteiras bloqueadas Rede principal + + UM + OUTRO + mês Taxa de rede O valor enviado será reduzido em %1$s (%2$s) para cobrir o nível de taxa selecionado @@ -481,6 +489,11 @@ Envie fundos usando apenas Endereços dinâmicos O recurso de Endereços Dinâmicos está ativado. As opções personalizadas \"alterar\" e \"indexar\" não estão disponíveis. + Você está desativando os endereços dinâmicos. Os fundos agora serão recebidos em seu endereço fixo. + Uma taxa de rede é aplicada para consolidar fundos em um endereço fixo. + Desativar endereços dinâmicos + Desativar endereços dinâmicos + Endereços dinâmicos desativados Endereços dinâmicos ativados Use um novo endereço para cada transação para reduzir a rastreabilidade e melhorar a privacidade na blockchain. Privacidade aprimorada @@ -563,6 +576,7 @@ Fornecedor Melhor tarifa Lista de advertências da FCA + A tarifa fixa não está disponível. Tarifa competitiva Fornecedor na lista de advertências da FCA Disponível até %s @@ -1624,9 +1638,18 @@ Retirada indisponível agora Você não pode iniciar uma troca ou um novo saque até que o atual seja concluído. Retirada em andamento + Mudar + Limite atual + Não foi possível carregar seu limite diário. Tente novamente. + Limite diário indisponível + Você pode alterar isso novamente quando quiser. + O limite diário está definido. + Limite diário Configurações do cartão Alterar código PIN Volte ao aplicativo se você se esquecer. + Defina um limite a partir de %s para %s + Definir limites Cartão digital Entendo que perderei completamente o acesso ao meu cartão Tangem Pay e a todos os fundos nele contidos, sem possibilidade de recuperação. Falha na emissão do cartão @@ -1700,6 +1723,9 @@ Vendendo %s Não é suportado pelos fornecedores atuais, mas estamos trabalhando para adicionar mais opções. O Staking %s não é suportado pelos provedores atuais, mas estamos trabalhando para adicionar mais opções. A aprovação foi revogada. Seus fundos permanecem no modo Yield. Para realizar ações, acesse o modo Yield e conceda a permissão novamente. + Saldo disponível + Saldo total + Ganhe até %s um ano Gerar XPUB Ocultar Você está prestes a ocultar este token da tela principal. Você pode adicioná-lo novamente a qualquer momento através da página de gerenciamento de tokens. @@ -2199,7 +2225,7 @@ Se as taxas de rede ultrapassarem a taxa máxima, a transação não será concluída até que elas diminuam. Você poderá alterar esse limite posteriormente. Taxa máxima O valor mínimo é calculado com base na tarifa de rede atual, garantindo que não ultrapasse 4.%% do valor da recarga, que é igual ao mínimo %1$s (%2$s). - recarga mínima + Recarga mínima Política de taxas de recarga Tangem também leva 15% Taxa de serviço sobre o rendimento gerado. Seus fundos serão transferidos automaticamente para a Aave assim que as tarifas de rede diminuírem ou seu saldo atingir o valor mínimo exigido. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index b0dc85a2c9..5800ceb0fe 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -80,6 +80,10 @@ Добавьте токены Выберите токен для получения Выберите токен для обмена + Добавить в портфель + Добавить токен + Сортировка и группировка + Упорядочить токены Выберите сеть Добавить токен Валюты @@ -279,6 +283,12 @@ дней дней + + %d день назад + + + %d дней назад + Удалить Отключить Отключено @@ -313,6 +323,12 @@ Скрыть Удерживайте, чтобы %s час + + %dч назад + %dч назад + %dч назад + %dч назад + Импортировать В процессе Недостаточный баланс @@ -323,6 +339,12 @@ Заблокирован Заблокированные кошельки Основная сеть + + %dмин назад + %dмин назад + %dмин назад + %dмин назад + месяц Комиссия сети Сумма отправки будет уменьшена на %1$s (%2$s) для покрытия выбранного уровня комиссии @@ -397,6 +419,7 @@ На На %s Сегодня + Токен к отправке %d токен %d токена @@ -434,6 +457,7 @@ Токен уже существует Десятичное число должно быть действительным целым числом, до %d Своя деривация + Включены динамические адреса. Пользовательские значения change и index недоступны. Например m/00\'/0000\'/0\'/0/0 Введите свою деривацию Знаков после запятой @@ -487,12 +511,23 @@ %s сеть Отправляйте средства, используя только Динамические адреса + Включены динамические адреса. Пользовательские значения change и index недоступны. + Вы отключаете динамические адреса. Теперь средства будут поступать на ваш текущий адрес без его изменения. + Отключить динамические адреса + Отключить динамические адреса + Динамические адреса отключены + Динамические адреса включены Используйте новый адрес для каждой транзакции, чтобы снизить отслеживаемость и повысить конфиденциальность в блокчейне. Повышенная конфиденциальность + Легко получайте средства в сетях на базе UTXO благодаря автоматической генерации адресов — без необходимости управлять адресами вручную. Бесшовное получение Включить динамические адреса + Динамические адреса каждый раз создают новый адрес для дополнительной приватности — ваш общий баланс при этом не меняется. Динамические адреса недоступны + Не удаётся подключиться к провайдеру. Попробуйте позже. Сервис недоступен. Пожалуйста, попробуйте еще раз. + Обнаружены средства на связанных адресах + Динамический адресс Лучшие возможности Очистить фильтр Список временно пуст — он обновляется. Пожалуйста, зайдите чуть позже. @@ -530,7 +565,7 @@ Отправленные средства были возвращены в %1$s на ваш кошелек в соответствии с правилами OKX или моста обмена. %2$s Сумма была возвращена в %1$s (%2$s сети) Посетите сайт провайдера для проверки - Провайдер запрашивает прохождение верификации + Требуется верификация у провайдера Покупка завершена Ожидание покупки Ожидание покупки... @@ -563,6 +598,7 @@ Больше провайдеров на подходе.\nСледите за обновлениями! Провайдер Лучший курс + Фиксированная ставка недоступна Лучший выбор Доступно до %s Доступно с %s @@ -587,6 +623,8 @@ Выберите какой токен будет использоваться для оплаты комиссии сети. %s Выбрать токен Рынок и Новости + Показать все + Показать меньше В тренде Информация ниже не является обязательной. Вы можете стереть её, если хотите. Расскажите, каких функций вам не хватает, и мы постараемся вам помочь. @@ -710,7 +748,9 @@ Приложите, чтобы отсканировать Приложите, чтобы подписать Приложите карту или кольцо + Кошелёк синхронизирован и готов к использованию.\nНе нашли какие-то токены? Кошелёк успешно импортирован + Восстановление %d%% Вы обновили данные биометрии, отсканируйте свою карту или кольцо для входа Ваш баланс должен быть выше суммы комиссии для осуществления перевода Недостаточно средств @@ -720,8 +760,10 @@ Лимит маны Сеть Koinos использует Ману для оплаты комиссии сети. У вас есть %1$s/%2$s Mana Уровень маны + Добавить и настроить Чтобы начать отслеживать свои криптоактивы и транзакции, добавьте токены Управление токенами + Отсканируйте QR-код, чтобы отправить средства или подключиться к приложению. Чтобы получить доступ ко всем сетям, вам необходимо отсканировать карту Отсканируйте карту или кольцо Обменивайте свои токены с %1$s комиссии провайдера через Changelly с %2$s по %3$s февраля. @@ -761,9 +803,13 @@ Доход с Tangem Чтобы создать адреса для выбранных сетей, отсканируйте вашу карту или кольцо Tangem кошелька Потяните вверх или коснитесь поисковой строки, чтобы добавить токен + Смахните вверх, чтобы изучить рынок Данные раздела получены из следующих сетей: %s Невозможно загрузить данные Нет данных + **Добавьте в свой портфель**, чтобы покупать, обменивать или получать этот актив. + В вашем портфеле + Ваш портфель Пульс рынка Быстрые действия Очистить всё @@ -850,6 +896,7 @@ Позиция в рейтинге криптовалют среди всех монет на основе рыночной капитализации. Рейтинг Макс. объем + Циркулирующее и максимальное предложение Максимальное количество монет или токенов, которое может когда-либо существовать для выбранной криптовалюты Максимальный объем Метрики @@ -863,6 +910,7 @@ Общ. предл. Максимальное количество монет или токенов, которое может когда-либо существовать для выбранной криптовалюты Общее предложение + 24ч Объем торгов (24ч) Общая сумма криптовалюты, которая была продана за последние 24 часа, показывающая уровень активности и ликвидности на рынке. Объем торгов (24ч) @@ -894,6 +942,7 @@ Связанные токены Связанные новости Будьте в курсе + Индекс популярности Функция NFC недоступна на вашем устройстве О NFT NFT @@ -1299,6 +1348,11 @@ Лимит транзакции Опционально Пожалуйста, совместите свой QR-код с квадратом, чтобы отсканировать его. Убедитесь, что вы сканируете адрес в сети %s. + При фиксированном курсе сумма, которую вы получите, фиксируется в момент обмена. Это защищает вас от изменения цены во время транзакции. + Фиксированный курс + Плавающий курс означает, что итоговая сумма, которую вы получите, может немного измениться в зависимости от рыночных условий в период между началом и завершением обмена. + Плавающий курс + Курс зафиксирован Последние Получатель Неверный адрес @@ -1577,9 +1631,12 @@ Карта разморожена Вывести Запрещено использовать на root-устройствах + Баланс Скрыть KYC с главной + Карта Tangem Pay Пополнить Способы пополнения + Добавить в Google кошелек Номер Сменить ПИН Карта готова к покупкам @@ -1609,22 +1666,29 @@ Скопируйте свой адрес или покажите QR Техническая ошибка. Попробуйте позже или обратитесь в поддержку. Пополнение недоступно + Можно вводить только буквы и цифры + Недопустимые символы Показать Реквизиты Пополните карту любым активом через обмен Реквизиты + Пожалуйста, попробуйте позже Разморозить карту Ваш ПИН Вывести Вывод сейчас недоступен Вы не можете начать обмен или новый вывод, пока не завершится текущий. Вывод выполняется + Настройки карты Изменить PIN-код Можно посмотреть здесь, если забудете его. + Виртуальная Я понимаю, что полностью потеряю доступ к своей карте Tangem Pay и ко всем средствам на ней без возможности восстановления. Не удалось выпустить карту Техническая ошибка, попробуйте ещё раз, нажав кнопку ниже Техническая ошибка, свяжитесь с поддержкой + Скоро будет доступно + Вы сможете выпускать дополнительные карты к счёту Откройте бесплатную виртуальную карту Tangem Visa Получить Tangem Pay Написать в поддержку @@ -1656,6 +1720,7 @@ Мы создадим отдельный платежный счет без раскрытия ваших активов \nи их адресов Абсолютная приватность Откройте виртуальную \nTangem Pay Card + Поддержка Pay Платежный аккаунт Платежный аккаунт не синхронизирован Слабый ПИН: не используйте повторы или последовательности. @@ -1663,9 +1728,11 @@ Сервис временно недоступен Не можем показать данные карты, но оплаты продолжают работать. Сессия истекла + Обновить сессию Оплачивайте ежедневные покупки в USDC Tangem Pay временно недоступен Tangem Pay + USDC в сети Polygon Нажмите на кнопку ниже, чтобы восстановить доступ Баланс ончейн-адреса (USDC Polygon) обновляется в течение 2 рабочих дней после покупки. При возвратах покупок средства не возвращаются на ончейн-баланс и недоступны для вывода, но отображаются на карте и могут быть использованы для покупок. Обратите внимание @@ -1687,6 +1754,7 @@ Продажа %s в данный момент не поддерживается ни одним провайдером. Мы работаем над добавлением новых возможностей. Следите за нашими новостями. Стейкинг %s в данный момент не поддерживается ни одним провайдером. Мы работаем над добавлением новых возможностей. Следите за нашими новостями. Разрешение было отозвано. Ваши средства остаются в Yield сервисе. Чтобы совершать операции, перейдите в Yield сервис и снова выдайте разрешение. + Общий баланс Сгенерировать XPUB Скрыть Вы скрываете токен с главного экрана, но в любой момент сможете добавить его обратно через страницу управления токенами. @@ -1696,8 +1764,11 @@ Сервис стейкинга %1$s токен в сети %%image%% %2$s Токен в сети %%image%% %1$s + Сеть %s + %1$s в %2$s %%image%% Токен %1$s (%2$s) является основной валютой в сети %3$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети Невозможно скрыть %s + N/A Показать QR код Обменивайте этот токен на другие с %1$s комиссии за обслуживание с %2$s по %3$s февраля. Обмен с Changelly, %s комиссии @@ -1740,6 +1811,9 @@ Произошла ошибка. Код ошибки: %s. Попробуйте, пожалуйста, снова. Если проблема будет продолжать возникать — обратитесь в службу поддержки. Используйте %s или отсканируйте карту/кольцо, чтобы получить доступ к своему кошельку Соединение не удалось: это dApp использует Wallet Connect версии 1.0, которая не поддерживается. Убедитесь, что dApp поддерживает Wallet Connect версии 2.0 для успешного подключения. + Предыдущее разрешение будет отозвано, и вместо него будет выдано новое. За каждое из этих действий сеть спишет комиссию за подтверждение токена. В истории транзакций появится операция с нулевой суммой как подтверждение отзыва разрешения. + Сумма транзакции превышает ранее выданное разрешение.\nОбновите разрешение, чтобы продолжить. + Обновите разрешение Апгрейд до аппаратного кошелька Будьте в курсе новых функций и новостей Мгновенные уведомления о транзакциях, обменах и важных обновлениях. @@ -2085,7 +2159,7 @@ Войти с %s Сканировать карту или кольцо Используйте %s или отсканируйте карту либо кольцо для входа в приложение - C возвращением! + С возвращением! Нет, отправить все Уменьшить на %s XTZ Чтобы не платить повышенную комиссию при следующем пополнении кошелька, уменьшите сумму на %s XTZ @@ -2160,7 +2234,7 @@ Все ваши будущие поступления %1$s будут автоматически направляться в Aave. Активен На паузе - Завершение режима доходности + Отключение режима доходности Выключив эту функцию, вы выведёте средства из Aave, получите их обратно в %s в своем кошельке и перестанете зарабатывать награды. Комиссия сети взимается блокчейном при выходе из режима доходности. Завершить режим доходности diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index ffcd7afc54..be2d491475 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -314,6 +314,12 @@ Приховати Утримуйте, щоб %s година + + %d годину тому + %d години тому + %d годин тому + %d годин тому + Імпортувати В процесі Пізніше @@ -323,6 +329,12 @@ Заблокований Заблоковані гаманці Основна мережа + + %d хвилину тому + %d хвилини тому + %d хвилин тому + %d хвилин тому + місяць Комісія мережі Сума відправлення буде зменшена на %1$s (%2$s) для покриття обраного рівня комісії diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 13a1972ba4..775c2091a0 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1,28 +1,54 @@ + 未设置访问码,您的钱包就不安全。 + 确定跳过 + 访问码未设置 更改代码 您的访问代码可解锁和保护您的钱包访问权限 无论如何都要用 这个访问码很容易猜到。 + 输入访问代码 + 访问密码错误。再有 %s次错误尝试后,您的手机钱包将被删除。 + 访问密码错误。再有 %s 尝试失败后,应用程序将被锁定。 + 访问码错误。\n请在 %s 秒后,重试。 + 请确认您的访问码以继续 + 重新输入访问代码 + 设置 %s-位数访问码,用于解锁您的钱包。 + 创建访问代码 + 访问码 您不能创建超过 %1$s 账户。存档一个账户以添加新账户。 无法添加新帐户 账户已存档 已存档账户 + 恢复 您即将恢复 \"%1$s\"。 恢复账户 您已达到20个活跃账户的上限。请归档一个账户以恢复此账户。 无法恢复帐户 + 已存档 我们无法存档账户。请稍后再试。 此账户正在参与推荐计划。 此帐户无法存档 我们无法创建账户。请稍后再试。 账户已创建 + 存档帐户 + 档案 + 您正在存档此帐户,但您可以随时恢复它。 存档... + 帐户 无法编辑帐户 账户已保存 奖励账户 + 帐户 #%s — 用于地址派生。 + 添加帐户 + 保存 + 帐户名称 已存在使用此名称的帐户。请选择其他名称。 帐户名已被使用 + 帐户 + 新账户 + 添加帐户 + 编辑帐户 请稍后再试。如果问题仍然存在,请联系客服,我们将协助您解决问题。 %1$s 在 %2$s 主账户 @@ -30,18 +56,110 @@ 无法恢复帐户 账户已恢复 长按账户可重新排序 + 继续编辑 + 丢弃 + 您确定要注销新账户吗? + 您确定要放弃修改吗? + 未保存的更改 一些自定义代币将自动从 \"%1$s\" 移至 \"%2$s\",因为它们的派生词属于该账户。 部分自定义代币将自动移动 + 找不到您的代币?请前往主页的“市场”版块,将其添加到您的投资组合中进行购买。 + 找不到您的代币?请前往主页的“市场”版块,将其添加到您的投资组合中进行出售。 + 出售 + 这可能需要几秒钟时间。请稍后再试。 + 数据尚未加载。 + 该操作目前不可用。请稍后再试,或在屏幕上向下滑动刷新数据。 + 操作不可用 + 选择代币 + 找不到您的代币?请前往主页的“市场”部分,将其添加到您的投资组合中进行兑换。 + 目前没有可用于兑换所选代币的代币。请选择其他代币。 + 没有可用的交易对 + 要使用兑换功能,您的投资组合必须至少包含 2 个代币。 + 添加代币 + 您的投资组合中仅添加了 1 个代币。要使用兑换功能,您至少需要添加 2 个代币。 + 添加代币 + 选择您想要接收的代币 + 选择您要兑换的代币 + 添加到您的投资组合 + 添加代币 + 排序和分组 + 整理代币 + 选择网络 + 添加自定义代币 + 管理代币 + 只能从 %3$s 网络发送 %1$s (%2$s) 到此地址。使用其他代币和网络可能会导致资金损失。 + 默认 + 传统 生物识别出现问题。请尝试重置设备上的生物识别技术或联系技术支持。 验证错误 + 如何扫描 + 请求支持 + 再试一次 + 此功能在演示模式下禁用。 + 原因: %s + 无法发送交易 + 所选功能不支持 %1$s 网络 + 要激活 %1$s 区块链的加密功能,您需要将钱包重置为出厂设置。请在重置前提取您的资金以确保不会丢失,然后完成重置过程。重置后将无法访问当前钱包。 + 由于固件限制,该卡片或指环不支持 %1$s 网络中的代币。 + 您是否在扫描卡片或指环时遇到困难? + 此卡片不适用于此应用程序 设置访问码以启用生物识别功能 + 使用 %1$s 解锁钱包并批准敏感操作,例如签署交易。对于硬件钱包,仍然需要使用卡片或指环进行签名。 + 默认费用 + 启用默认手续费功能,即可自动设置交易手续费,并在汇款时跳过手续费页面。如有需要,您可以随时返回此页面进行设置。 + 进入设置,在 Tangem 应用程序中启用生物识别身份验证 + 启用生物特征认证 + 禁用 %1$s 您需要输入密码才能解锁应用程序并与您的钱包进行交互。 稍后会要求您提供访问代码以进行安全存储。 + 这将删除所有已保存的钱包访问代码。您需要重新输入访问代码才能使用钱包。 + 删除已保存的设备会从应用中删除所有已保存的钱包及其访问代码。 这将删除所有已保存的钱包访问码。您需要重新输入访问码才能使用钱包。 + 需要访问代码 + 此选项将关闭敏感操作的生物识别功能。每次签署交易时,您都需要输入访问码。 + 保存访问代码 + 使用卡片或指环进行交互时,将需要生物识别认证,而不是访问代码。 + 将钱包保存在应用程序中 + 启用此功能后,即可将所有钱包关联至 Tangem 应用。解锁应用需要进行生物识别认证。交易签名需要轻触您的 Tangem 卡片或指环。 + 黑暗 + 明亮 + 系统默认设置 + 主题 + 应用设置 + 添加钱包 + 选择钱包登录 + 欢迎回来! + + %d卡片 + + 移动钱包 + 您已成功备份钱包。 + 这些单词一旦丢失就无法找回,请妥善保管。 + 备份完成 + 您的恢复密语是一组 %s 随机单词,用于访问和恢复您的钱包。 + 如果您丢失了助记词,就无法找回。请务必妥善保管。 + 妥善保管 + 将这些 %s 单词保存在只有你能访问的安全地方,千万不要与任何人分享。 + 一旦丢失无法恢复 + 恢复短语 + 以下 %s 单词是您钱包的恢复短语。切勿与任何人分享。Tangem 不会向您索要。如果您丢失了设备,请使用它们恢复您的钱包。 + 按数字顺序写下这些 %s 单词,并妥善保管。 + 保护钱包安全和安全备份恢复短语完全是您的责任。 + 恢复短语 + 要隐藏或显示余额,只需向下翻转设备屏幕,或在“设置”中将其关闭。 + 不要再次显示 + 明白 + 余额已隐藏 + 据区块链开发者称,Kaspa代币目前处于测试阶段。敬请关注后续更新! + 测试模式 您的设备已关闭生物识别功能,因此无法使用此功能解锁钱包。请在设备设置中启用生物识别功能,即可再次使用此方法。 生物识别认证已禁用 + 请扫描卡片或指环 您的生物识别尝试次数已达上限。请使用卡片/指环解锁钱包,或输入您的访问码。 生物识别认证已锁定 + 请30秒后再试,或扫描卡片或指环 生物识别登录暂时锁定。请30秒后重试,或轻触设备或输入访问码解锁您的钱包。 + 尝试次数过多 + 您的手机已禁用生物识别身份验证,因此无法在应用程序中保存钱包。要保存钱包,请在手机设置中启用生物识别身份验证功能。 您设备上的生物识别信息已更新。请选择您的钱包并输入其访问码以重新启用生物识别登录。 需要注意 处理您的优惠码时出错,请稍后再试。 @@ -54,6 +172,14 @@ 无效代码 您需要提供比特币地址才能领取奖励。请将比特币地址添加到您的钱包,然后重试激活。 需要比特币地址 + 启动备份程序 + 使用银行卡或其他支付方式 + + %d设备 + + + %d代币 + 请重置下一个设备以继续。 钱包重置 所有 Tangem 设备均已重置。您现在可以继续升级钱包。 @@ -61,58 +187,307 @@ 重置完成 我们建议在此钱包中完成所有 Tangem 设备的重置过程。 部分 Tangem 设备仍需重置。 + 如果您不希望此卡用于重置此钱包中其他卡片或指环的访问码,请禁用此选项。请注意,禁用此选项后,您也无法重置此卡的访问码。 + 您可以使用此卡重置此钱包中其他卡片的访问码。 + 恢复访问代码 + 重置 + 你确定要这么做吗? + 更改访问代码 + 仅在此卡片或指环上更改访问密码 + 所选钱包中的所有 Tangem 设备均已恢复出厂设置。您现在可以创建新钱包。 + 重置完成 + 您想重置此钱包中的下一个设备吗? + 钱包重置 + 我们建议您完成此钱包中所有 Tangem 设备的重置流程。 + 您尚未重置所有 Tangem 设备 + 恢复出厂设置 + 安全模式 + 设备设置 + 使用 %2$s 代币进行交易时,除网络费外,Cardano网络还收取 %1$s ADA + Cardano交易要求 + 要进行 %1$s 交易,您必须存入一些 ADA 以支付网络费用和最低 ADA 值(建议存入 5 ADA)。 + ADA不足以进行代币转移 + 发送金额及找零不得少于 1 ADA。 + 您必须持有一定数量的 ADA,因为您在 Cardano 区块链上有一些代币。 + ADA不足 + 接受 + 拒绝访问 账户 账户 激活 添加 + 添加到投资组合 + 添加代币 添加代币 已添加 + 地址 + 全部 + 允许 + 金额 + 分析 + 申请 + 批准 + 批准 + 请注意 + 可用网络 + 备份 + 余额: %s + 余额 + 生物识别认证 + 生物识别 + 创建交易失败 + 购买 + 前往 %1$s + 您未授权访问您的相机,请调整您的隐私设置 + 取消 + 更改 选择账户 + 选择行动 + 选择网络 + 选择代币 + 选择钱包 + 提取 + 领取奖励 + 关闭 + 即将推出 + 确认 + 连接中 联系客服 + 联系 Tangem 技术支持 + 联系VISA技术支持 + 继续 + 转换 + 复制 + 复制地址 + 创建 + %1$s (%2$s) + 定制 + + %d天 + + + + + + %d天之前 + + 删除 + 禁用 + 已禁用 + 断开 + 完成 + 编辑 + 启用 + 已启用 + 错误 充值网络费 + 兑换 + 探索 + 查看交易历史 + 查看 + 未能获得费用 + 网络费是用户为处理和确认交易而支付的费用。费用金额会受到网络拥堵、交易规模和执行优先级的影响。 %s + 较快 + 市场 + 较慢 + 速度和费用 + 完成 忘记 + 免费 从 %s + 同步地址 开始 获取代币 + 前往服务提供商 + 前往代币 + 知道了 + 隐藏 保持到 %s + 小时 + + 小时之前 + + 导入 + 进行中 余额不足 + 稍后 了解更多 + 剩余%1$s天 传统比特币 + 已锁定 锁定的钱包 + 主网 + + 分钟之前 + + + 网络费用 + 汇款金额将减少 %1$s (%2$s)以支付所选费用等级 %d网络 新地址 新闻 + 下一个 + NFT + + 无地址 无结果 + 未添加 不可用 + 现在不要 + 现在 + 好的 + 在浏览器中打开 或者 + 主卡 + 主指环 + 密码短语 + 粘贴 + 隐私政策 + %1$s-%2$s + %1$s — %2$s + 阅读更多 + 接收 推荐 + 拒绝 + 重新加载 + 重命名 + 必需的 重置 + 节省 + 保存更改 + 搜索 + 搜索代币 + + 查看全部 + 助记词 + 选择操作 + 出售 + 发送 + 交易发送失败 + 服务器不可用,请稍后再试。 + 分享 + 分享链接 + 显示更少 + 显示更多 + 签名 + 签名并发送 + 跳过 出问题了 + 质押 + 质押 + 开始 + 提交 + 成功 + 支持 + 支持的网络 + 兑换 Tangem + Tangem钱包 点击并按住 + 条款和条件 + 使用条款 到 %s + 今天 要发送的代币 + + %d代币 + + 交易失败 + 交易状态 + 交易 + 转让 无法加载数据…… + 我明白 我明白,请继续 + 出现错误,请重试。 解锁 + 无法连接 + 取消抵押 + 由于 %1$s 的限制,一次交易只能发送 %2$d 个UTXO。这意味着您只能发送 %3$s 或更少。您需要减少金额。 + 通用值已拷贝 钱包 + 警告 + + + 收益模式 + 合约地址已复制! + 可用网络 您的代币派生信息与 %1$s的派生信息一致。 您的代币将被添加到该账户。 派生属于另一个账户。 代币已添加到 %1$s 帐户 + 合约地址 + 合约地址无效 + 请选择网络 + 此代币已添加到列表中 + 代币已存在 + 小数部分必须是有效的整数,最大不超过 %d + 自定义派生 动态地址已启用。自定义\“更改”\和\“索引”\不可用。 + 例如:m/00\'/0000\'/0\'/0/0 + 输入自定义派生 + 小数 + 派生路径 + 默认 + BIP44 代币类型 + 您输入的派生路径无效 + 例如USD币 + 名称 + 未选择 + 网络 + 代币网络 + 您可以手动添加 Tangem 本身不支持的代币 + 例如 USDC + 符号 + 代币符号 + 此代币/网络已添加到您的列表中 + 请注意,任何人都可以创建代币。谨防添加诈骗代币,它们可能不值一文。 + 请注意不要添加诈骗代币,它们可能分文不值。 + 请注意,任何人都可以创建代币 + 购买 Tangem 钱包 + 聊天 + 访问代码 + 扫描卡片前,您必须先输入正确的访问码。 + 长按 + 此机制可防止对卡片或指环的近距离攻击。它会在接收命令和执行命令之间强制设置延迟。 + 密码 + 在执行任何需要改变卡状态的命令之前,您必须输入密码。 升级到硬件钱包 + NFT + 推荐计划 + 向下翻转设备屏幕即可快速隐藏和显示余额。 + %s 哈希值 + 设备 ID + 开启技术支持聊天 + 链接更多卡片 + 应用程序货币 + 翻转隐藏余额 + 发行人 + 已签署 + 发送反馈 + 详情 您只能拥有一个手机钱包。将其升级为 Tangem 硬件钱包,或添加一个新的硬件钱包。 + 检查您的互联网连接或切换到其他网络 + 服务条款 默认地址 传统 %s 地址 + 接收资产 %s 地址 + 将资产发送到其他网络会导致永久性损失。 + %s 网络 + 仅使用以下方式汇款 动态地址 动态地址已启用。自定义“更改”和“索引”功能不可用。 + 您已关闭动态地址功能。资金将由您的固定地址接受。 + 将资金集中到固定地址需要支付网络费用。 + 禁用动态地址 + 禁用动态地址 + 动态地址已禁用 已启用动态地址 每次交易都使用一个新地址,以减少可追溯性并提高链上隐私性。 增强隐私 @@ -124,6 +499,9 @@ 动态地址不可用 我们目前无法连接到服务提供商,请稍后再试。 服务不可用,请稍后再试。 + 在其他地址发现了资金。启用动态地址即可访问这些地址。 + 在其他地址发现的资金 + 动态地址 最佳机会 清除筛选 列表正在刷新,暂时为空。请稍后再查看。 @@ -135,18 +513,122 @@ 热门 无结果 质押与收益模式 + 您好,技术支持团队,我遇到了代码错误: %s + WalletConnect 错误 + 您使用了另一个钱包中的卡或指环。点击与此钱包关联的卡或指环 + 账户余额不足,请充值。 将您投资组合中的任何资产交换为该代币 + 我的代币 + 您尚未添加任何代币。请通过市场添加代币进行兑换。 + 无法兑换 %s + 提供者 + 状态 + 选择提供商 + 提供商提供的交易 + 提供商 + 发生错误。错误代码: %s + 错误 %1$s。所选提供商无法处理指定交易。请将数值四舍五入至 %2$s 或更改数值 + 所选服务提供商目前不可用。请稍后再试。(代码:) %s) + 目前无法进行兑换。请稍后再试。(代码:) %s) + 预估金额 + 由 %s交易 + 访问提供商网站退款 + 提供商操作失败 + 您的兑换所需时间比平时长,但您的资金完全安全并会到账。如有任何疑问,您可以联系服务提供商的客服团队。 + 交易时间长 + 由于 OKX 或桥接规则的原因,交易金额以 %1$s 的形式退还至您的钱包。 %2$s + 退款已退还到 %1$s (%2$s 网络) 。 + 访问提供商网站进行验证 + 提供商要求进行 KYC 验证 + 购买已完成 + 等待购买 + 等待购买…… + 交易已取消 + 存款已确认 + 等待确认 + 等待确认…… + 交易完成 + 等待交易 + 等待交易... + 交易失败 + 交易暂停 + 收到存款 + 等待存款 + 等待存款…… + 退款已完成 + 等待退款 + 发送给您 + 发送资金... + 已汇款 + 数据由提供商提供。预估金额可能因市场情况而有所变动。 + 交易状态 + 需要验证 + 等待交易哈希 + 已添加到您钱包的所有代币列表 + 获取当前汇率... + 浮动利率 + 使用兑换功能即表示您同意提供商的条款 %s + 使用兑换功能即表示您同意提供商的条款 %1$s 和 %2$s。 + 更多服务提供商即将推出 + 提供商 + 最佳汇率 + FCA警告清单 + 固定利率不可用 有竞争力的费率 + 被列入 FCA 警告名单的提供商 + 最多可 %s + 可用 %s + 此交易对不可用 + 需要许可 + 推荐 + 已购买 %s + 购买 %s + 购买 %s... + 隐藏此交易 + 如果您隐藏此交易,它将不再显示在状态屏幕上。如果您只想关闭状态屏幕稍后再返回,只需将其滑动移除即可。 + 隐藏交易状态? + 此代币不受支持。请选择其他代币进行兑换。 + %s 不支持 + 互换 + 未找到代币。请尝试其他请求。 + ID: %s + 交易 ID 已复制 + 将您投资组合中的任何资产兑换成此代币。 速度越快,确认速度越快,但网络费用也越高。 %s 选择速度 选择用于支付网络费用的代币。 %s 选择代币 市场与新闻 + 显示全部 + 显示更少 Tangem AI 当下热门 + 以下信息为可选信息。如果您不想分享,可以将其删除。 + 请告诉我们您缺少哪些功能,我们会尽力帮助您。 + 请告诉我们您拥有的卡片或指环是什么。 + 您好,技术支持团队, + 请详细描述您的问题。每一个细节都可能对我们有所帮助。 备份问题 + 之前激活的钱包 + 我的建议 + 无法扫描卡片/指环 + 反馈 + Tangem反馈 + 无法发送交易 + 代币描述错误 资金不足 转账费 + 发生错误 + 发生错误。错误代码: %s。 + 需要备忘录 + 交易 + 批准后,您即允许智能合约在未来的交易中使用您的代币。 + 金额 %s + 您需要使用“批准”功能来授权其他地址使用您指定数量的代币。根据设计,智能合约只有在您批准后才能访问您的代币。通过“解锁”您的代币,您授权 StakeKit 智能合约使用它们。网络矿工会收到 Gas 费(由您支付)来记录区块链上的此操作。批准后,您可以质押您的代币。 + 要继续,您需要允许 Polygon 智能合约使用您的 %s + 要继续,请授权 %1s 智能合约使用您的 %2s + 给予许可 + 无限制 地址直接在您的 Tangem 硬件钱包上生成,随时可用,并受到全面保护。 新地址 添加 Tangem 钱包 @@ -154,25 +636,54 @@ 密钥生成 所有加密操作都在安全芯片内部进行,该芯片经过认证,可防止克隆和物理篡改。 硬件级安全 + 添加现有钱包 + 创建新钱包 + 订购 Tangem + 扫描 Tangem 您是否允许“Tangem”使用生物识别认证来确认您的身份并打开应用程序? + 到 %s + 在 %s 网络中 + 您确定要取消访问码设置吗? + 立即备份 + 要完成设置,请备份您的钱包并使用访问码保护应用程序。 + 立即完成 + 完成钱包设置 + 使用访问码保护应用程序,完成设置。 + 如果确定现在退出,您需要从头再来。 + 您确定要取消激活吗? 让您的加密货币安全离线存储。轻薄如信用卡,安全胜过银行金库。 + 如果确定现在退出,您需要从头再来。 + 通过 Google 云端硬盘备份恢复现有钱包 + Google 云端硬盘备份 创建一个安全的钱包并转移资金,以加强保护。 创建新钱包 使用卓越的 Tangem 硬件钱包,提升您的安全保障。 硬件钱包 将您当前的手机钱包转换为 Tangem 冷钱包。 升级当前钱包 + 转到备份 + 创建访问码之前,请备份您的钱包。 先完成备份 + 请先完成备份 + 不完整 其他方法 将您的恢复短语保存在安全的地方,保持其私密性以保护您的资金,并设置访问密码以提高安全性。 + 请将您的助记词保存在安全的地方并妥善保管,以保护您的资金安全。 + 恢复短语 要使用访问码保护您的钱包,请完成备份过程。 要升级到硬件钱包,请完成备份过程。 将您的手机钱包升级到 Tangem 硬件钱包,享受最高级别的安全性。导入您的钱包或将资金转移到新钱包。 升级到冷钱包 + 您的私人密钥已安全加密并存储在手机中 + 私钥会保留在您的设备上。 + 使用恢复短语创建或恢复钱包。 + 助记词备份 + 创建移动钱包 随时安全地将手机钱包转移到 Tangem 卡片或指环上。 升级到硬件钱包 导入现有钱包 此恢复短语已导入 + 移动钱包 忘记钱包 此钱包将从您的设备中永久移除。 你确定要这么做吗? @@ -205,31 +716,178 @@ 升级到我们的硬件钱包 使用 Tangem 的顶级硬件钱包,确保您的加密货币安全。 将您的钱包升级到硬件安全级别 + 此信息由人工智能生成。\n如果您发现任何错误,请点击这里。 + 要更改访问密码,请按上述方式轻触卡片或指环,并在操作完成前不要移开。 + 要更改密码,请按上述步骤轻触卡片,并在操作完成前不要移开。 + 要创建钱包,请按上述步骤轻触卡片,并在操作完成前不要移开。 + 要创建钱包,请按上图所示轻触指环,并在操作完成前不要松开。 + 点击钱包的卡片#%s + 轻触以扫描 + 点击签名 + 轻触卡片或指环 您的钱包已同步并准备就绪。\n缺少某些代币? 钱包已成功导入 恢复 %d%% + 您的生物识别信息已更新,请扫描您的卡片或使用指环进入。 + 您的余额必须高于转账手续费才能进行转账。 + 余额不足 + 您的Mana值不足以进行本次交易。请稍候,直到Mana值恢复。您的Mana值余额为: %1$s/%2$s + Mana不足 + 由于 Koinos 网络的Mana限制,您只能传输 %s 。 + Mana值上限 + Koinos 网络需要 Mana 来支付网络费用。您有 %1$s/%2$s Mana + Mana等级 + 添加和管理 + 要开始追踪您的加密资产和交易,请添加代币。 + 管理代币 扫描二维码即可发送资金或连接到应用程序 + 要访问所有网络,您需要扫描该卡。 + 扫描您的卡或指环 + 从 2 月 %2$s-%3$s起,通过 Changelly 进行兑换交易可享受 %1$s 的服务费 + 用Changelly 兑换, %s 费用 + 代币 + 添加 + 编辑 + 代币市值 + 区块链这种加密货币最初是被创建的。 + 原生网络 + 使用非原生网络发行代币可以实现跨区块链互操作性,使资产能够在各种去中心化应用和智能合约中使用。然而,这通常需要托管机构或智能合约来安全地持有原始资产,从而引入中心化和交易对手风险。 + 不是托管代币的原始或主要区块链 + 非原生网络 + 选择网络 + 钱包 + 找不到此代币,您可以手动添加。 + + %2$d钱包的%1$d + + 移除 + 例如,比特币 + 您的投资组合已更新 + 所选代币目前无法在加密钱包中进行操作。不过别担心,您可以点赞来表达您的兴趣。 + 点赞 + 该钱包不支持多个网络 关于硬币 + 要购买、交换或接收此资产,请将其添加到您的投资组合中。 + 钱包目前不支持此资产。 + 此资产不适用于此钱包。 + 添加 + APY %s + 市场价格 + 我的投资组合 + 市场 质押与收益模式 + 要为选定的网络生成地址,您必须扫描您的 Tangem 钱包卡或指环 + 要添加代币,请打开此页面或点击搜索栏 向上轻扫,探索市场 寻找新的隐藏宝石 + 本节数据来源于以下网络: %s + 无法加载数据…… + 无数据 **添加到您的投资组合**,即可开始购买、交换或接收此资产 在您的投资组合中 + 您的投资组合 市场脉搏 + 快速行动 全部清除 + 搜索代币 最近的 在您的投资组合中 + 结果 + 查看市值低于 10 万美元的代币 + 显示代币 加密货币、新闻及更多 + 无结果 + 选择网络 + 选择钱包 + 1个月 + 1年 + 24小时 + 3个月 + 6个月 + 7天 + 全部 + 经验丰富的买家 + 市值 + 排序方式 + 收益者 + 亏损者 + 热门话题 收益模式 + 质押是获取加密货币奖励的最简单方式。 %s + 年利率最高可达 %s 代币已添加 + 关于 %s + + 交易所 + + + 基于%d排名 + + 网站 + 买入压力 + 买方交易量与卖方交易量之差 + 买入压力 + 循环供应 + 市场上可供交易和流通的代币总数 + 循环供应 + 未找到交易所 + 交易 + 注意 + 风险 + 可信 + 交易所 + 经验丰富的买家 + 净买家还需满足至少100笔支出交易的要求 + 活跃买家 + 完全稀释估值 + 加密货币的理论总价值如果所有可能存在的代币都在流通,包括目前没有流通的币, + 完全稀释估值 + 创世日期 + + 持有者 + 特定时间段内代币持有者数量的变化 + 持有人 + 洞察 + 链接 + 流动性 + 在指定时间段内,代币可用流动性的变化 + 流动性 + 流动性指数 + 已列入 + + 市值 + 加密货币的总市值,计算方法是将该币种的当前价格乘以流通中的代币总量。 + 市值 + 市场地位 + 根据市值在所有加密货币中排名 + 市场地位 + 最大供应量 循环和最大供应 + 特定加密货币所能存在的最大代币或通证数量。 + 最大供应量 + 指标 无限制 + 官方链接 + 价格表现 + 存储库 + 安全评分 + 代币安全评分是根据各种因素评估区块链或代币安全级别的指标,数据来源于以下列出的来源。 + 社会 + 总供应量 + 特定加密货币所能存在的最大代币或通证数量。 + 总供应量 24小时 + 交易量(24小时) + 过去 24 小时内交易的加密货币总量,表明市场的活跃度和流动性水平 + 交易量(24 小时) %s 总共 + 总量 + 打开此页面或点击搜索栏,即可直接从市场添加代币。 + 添加代币 添加更多代币 增强资产性能,并使其能够即时访问。 %s 激活收益模式 @@ -249,11 +907,66 @@ 相关新闻 随时了解最新动态 趋势得分 + 您的设备不支持NFC功能。 + 关于 NFT + NFT资产 + + %d条目 + + 发送到您钱包地址的 NFT 将显示在此处。 + 暂无收藏集 + 接收NFT + NFT收藏集 + 部分数据可能无法加载 + 临时加载问题 + 基础信息 + + 合约地址 + Chain 是 指NFT 所在的区块链。 + 合约地址是管理区块链上代币的智能合约的唯一标识符 + 用于描述NFT稀有程度的标签。数值越低,NFT越独特。 + NFT 在所有代币的稀有度排名中的位置。排名越高,NFT 越稀有,价值越高。 + 代币地址是代币在区块链上的唯一标识符,可追踪交易和所有权 + 代币 ID 是分配给每个代币的唯一标识符,用于将其与收藏集中的其他代币区分开来。 + 代币标准定义了代币的类型以及如何与不同的钱包和平台协同工作 + 最后成交价 + 稀有度标签 + 稀有度等级 + 代币地址 + 代币 ID + 代币标准 + 特征 + 没有结果。请尝试其他请求。 + 无收藏集 + 可用 + 选择网络 + 接收NFT + 您尚未添加此网络。要接收 NFT,请将其添加到您的投资组合。 + 未添加网络 + 不支持的NFT类型 + cNFT 和 pNFT 目前尚不支持。请勿将其发送到您的钱包。 + 发送 NFT + 特征 + 无题收藏集 + %1$d NFT在 %2$d 收藏集中 + + %1$dNFT在%2$d收藏集中 + + 点击此处即可获得首个NFT + NFT收藏集 + 无法加载数据 + 使用 %1$s 网络,您必须支付账户储备金(%2$s %3$s),它会将该金额无限期地锁定和隐藏起来。 + 目标账户未激活。发送 %s 或支付更多费用以激活账户。 + 要创建账户,请将资金汇至此地址 + 目标账户没有为所发送的资产建立trustline。 每个钱包均可获得价值 10 美元的 BTC \n快来领取! 黑色星期五:最多可省 30 美元% 我们出发吧 限时优惠! 1+1:买一个钱包,优惠 50% + 立即加入 + 分享您的代码 - 每次销售赚取 5 USDT。您的朋友可获得 10% 优惠。 + 每邀请一位好友即可获得奖励! 购买加密货币 通过 SEPA 转账购买加密货币时,可享受 ** 0% 费用**。 使用 SEPA 购买加密货币 @@ -262,21 +975,141 @@ 条款和条件 存款 100 美元以上,持有 30 天,即可获得 10 美元 加入 \"收益模式 \"活动 + 设置一个统一的访问代码来保护您的所有设备。 + 保护 + 稍后为每张卡或指环设置单独的访问密码。 + 个性化 + 使用关联的卡片或指环恢复访问代码。不要把所有设备都放在同一个地方。 + 恢复 + 选择您想要的任何单词、短语或数字作为访问代码 + 创建访问代码 + 为避免出错,请再次输入您的访问码。 + 重新输入您的访问代码 + 访问代码长度必须至少为 4 个字符 + 输入的访问代码与初始访问代码不匹配 + 请重复此操作。该卡将恢复出厂设置。 + 激活错误 + 添加代币 + 您已添加了一个备份卡或指环。一旦备份完成,就不能再添加更多设备。如果您还有一张卡或指环,请现在添加。要继续吗? + 备份仅部分完成,现在无法退出。 + 密码短语是一项可选的安全功能,它会在您的恢复短语中添加一个单词或短语,从而创建一组新的钱包地址以获得额外的保护。 + 添加一张卡片或指环 + 扫描卡片 + 扫描卡#%d + 立即备份 + 扫描卡 + 扫描指环 + 继续前往我的钱包 + 完成备份 + 接收加密货币 + 扫描主卡或指环 + 跳过稍后再操作 + 它是如何工作的? + 让我们生成您卡片或指环上的所有密钥,并创建一个安全钱包。 + 创建钱包 + 创建钱包 + 其他选项 + 您的密钥将在芯片内部安全生成,没有助记词,这意味着任何人都无法导出或窃取它。 + 私下生成密钥 + 您的卡已激活,可以使用了。 + 成功! + 您的钱包已设置完毕,可以使用了! + 在这种情况下,您需要从头开始。 + 您是否要退出激活过程? + 开始 + 您尝试添加的这张卡上已经创建了另一个钱包。如果该钱包中有资金,请先取出资金,然后重置此卡并将其添加为备用卡。 + 保存您的钱包 + 使用生物识别技术 + 创建备份 + 最后一步 生物识别 + 了解更多关于助记词的信息 + + + + 您的助记词 + + + + 要导入您的钱包,请在下方字段中输入您的助记词。 + 生成助记词 + 导入钱包 + 助记词是一串单词,可用于找回您的钱包。与用卡片或指环生成的密钥不同,助记词不受保护,容易被复制和盗用。使用此功能需自行承担风险。 + 使用助记词 + 助记词无效。请检查词序。 + 助记词无效。请检查拼写。 + 传统 + 为了检查您是否正确记下了助记词,请输入第 2、7 和 11 个单词。 + 那么,我们来检查一下。 + 要启动备份程序,最多可添加两张备份卡或指环。 + 您可以再添加一张卡或指环,或完成备份过程 + 准备带有编号%s的备份卡 + 扫描主卡或指环以启动备份程序。 + 准备带有编号%s的主卡 + 准备好指环,然后按下下方的扫描按钮。 + 您的钱包已配置完毕,可以使用了。 + 已添加最大设备数量。完成备份过程。 + 激活钱包 + 备用卡片 + 备份卡 #%d + 备用指环 + 无备份设备 + 通知 + 新增一个备份设备 + 准备好你的卡片或指环 + 新增两个备份设备 + 要开始使用,只需为钱包充值,金额不限 + 要开始使用,只需在钱包中充值超过 %1$s %2$s + 购买加密货币 + 显示钱包地址 + 要开始使用,只需在钱包中充值超过 %1$s + 激活钱包 + 备份过程仅部分完成,您现在无法退出。 + 如果创建钱包的过程以任何方式中断,则您必须重新开始 + 您可以将密钥备份到另外两张空白的 Tangem 钱包卡或指环上。 + 可使用其中一张备份卡恢复访问密码。 + 所有备份卡均可使用全部功能并有相同的密钥。 + 您可以设置访问码来保护您的钱包。 + 备份钱包 + 恢复访问代码 + 相同的卡片 + 访问代码 您只能拥有一个手机钱包。将其升级为 Tangem 硬件钱包,或添加一个新的硬件钱包。 所有优惠 + 可用 %s + 服务提供商促进交易 + 按国家/地区搜索 + 不可用 + 其他货币 + 热门法币 + 按货币搜索 此交易已处理完毕,无需进一步操作。 获得最佳利率... 即时 + 使用 onramp 功能即表示您同意提供商的 %1$s 和 %2$s 服务由外部供应商提供。\nTangem对此不承担任何责任。 + 购买金额不应超过 %s + 购买金额必须至少 %s + 目前没有提供此货币的供应商 最快处理 + 支付方式 付款方式 + 最多可 %s + 可从 %s 提供者 提供商 最近使用过 推荐 + 您将可以在第三方供应商完成交易、 %s + 正在重定向到 %s... + 我们的服务在这个国家不可用。 + 您的住址已被确定为 + 居住地 + 请选择正确的国家/地区,以确保支付选项和服务准确无误。 + 设置 + 您可以关闭此屏幕,并在代币详情屏幕上查看交易状态。 至多%d天 @@ -285,10 +1118,23 @@ 最高可提供 你得到 服务由外部供应商提供。\nTangem 不承担任何责任。 + 您可以关闭此屏幕,并在代币详情屏幕上查看交易状态。 至多 + 通过 通过 %s 您将支付 + 分组 + 按余额 + 整理代币 + 取消分组 %s 支持 + 更多信息 + 您可以在设置中启用 Tangem 的通知。 + 稍后启用 + 设置 + 启用通知 + 接收受支持网络上的新交易提醒 + 交易通知 从图库中选择 设置 您尚未授予摄像头访问权限 @@ -301,56 +1147,411 @@ 未找到支持的代币 此二维码包含无法识别的参数: %s如果您继续操作,部分支付信息可能会丢失。 未知参数 + 无需备忘录 + %1$s (%2$s) 在 %3$s 网络 + %1$s 在 %2$s 网络 + 发送任何其他货币都将导致不可逆转的损失。 + 仅向该地址发送 %s 。发送任何其他代币都将导致不可挽回的损失。 + 仅在 %2$s 网络上发送 %1$s + 从任何钱包或交易所转账 奖励地址 + 参与 + 推荐计划信息加载失败,请稍后再试。 + 推荐计划信息加载失败。错误代码: %s请稍后再试。 + 即将得到的款项 + 您的朋友买了 + 少于 + 多于 + 近期无付款 + + 为%d个钱包 + + 您的朋友在您的 %2$s 网络地址 %3$s 每购买一个钱包,^^30 天后^^,您将获得 ^^%1$s^ + + 将获得 + 在 tangem.com 上购买钱包时 + %s 折扣 + 您的朋友 + 个人代码已复制! + 您的个人代码 + 以折扣价购买 Tangem 钱包!\n%s + 向您的朋友推荐 Tangem + 您已接受 + 点击此按钮即表示您接受 + 推荐计划 + + %d钱包 + + 重置卡片 + 我明白执行此操作后,我将无法再访问当前钱包。 + 我知道我不能用这张卡恢复当前钱包中其他卡片的访问密码 我明白,我将完全失去对 Tangem Pay 卡及其上所有资金的访问权,且无法挽回 + 恢复出厂设置会将所选卡片或指环上的钱包数据彻底清除。您将无法恢复当前钱包,也无法使用此卡片或指环找回访问码。 + 恢复出厂设置会将所选卡片或指环上的钱包数据彻底清除,并将其从应用程序中移除。您将无法恢复当前的钱包数据。 所有 Tangem 设备均已重置。 激活过程中出了问题。请逐一重置卡片。 卡片验证失败 请重置下一个设备以继续 + 戒指拥有者在 Changelly 上可享受 3 次免佣金兑换,截止日期为 11 月 15 日! + 立即兑换,享受0% 费用! 拥有 root 权限的设备安全性较低。您的数据可能面临额外的风险。 检测到 root 访问 + 登录应用程序,无需扫描卡或指环即可查看余额 + 访问应用程序 + 允许使用生物识别技术 + 与您的钱包互动时,将要求使用生物识别,而不是访问代码 + 访问代码 + 不允许 + 您似乎已禁用生物识别认证,但这对于保存钱包信息至关重要。 + 启用生物识别授权 + 您想使用生物识别认证吗? + 请注意,使用您的资金进行交易仍需要您的卡片或指环 + 扫描卡片或指环 + 扫描卡片或指环以更改其设置。更改只会影响您扫描的卡片或指环,而不会影响与钱包绑定的其他设备。 + 准备好你的 Tangem! + 安全警报 + 不,我没有 + 是的,请指引我 + 您的账户余额不足以出售加密货币。请存入所需资产以继续操作。 + 余额不足 + 目前您所在的地区尚不支持出售加密货币。我们正在积极努力,争取尽快为您开通此功能—敬请期待! + 区域限制 + 已包含在输入的地址中 + 您的佣金金额比建议用量高出 %s 倍。请检查并调整您的自定义设置。 + 您指定的佣金低于建议金额,这可能会导致交易延迟。是否继续? + 原因: %1$s\nCode: %2$s + 交易尚未完成 + 兑换成其他代币或网络 + 金额 + 将发送给收款人 + 您可以通过调整每 vByte 聪 (Satoshi per vByte) 字段中的值来设置交易手续费。 + 本次交易将收取的手续费。您可以自行设定金额。 + 最高费用 + 这是您愿意为每单位Gas费支付的成本。Gase费价格越高,您的交易处理速度就越快。(已包含优先处理费) + 优先处理费 + 用户向矿工或验证节点支付的费用,用于加快将其交易写入区块的速度。 + Kaspa网络中使用每个未花费交易输出(UTXO)所需的费用。交易中使用的UTXO越多,费用越高。 + KAS 每个 UTXO + %1$s%2$s + 目的地址标签 + 输入地址 + ENS名称或地址 + 地址与钱包地址相同 + 最低金额为 %s + 最小找零是 %s + 无效费用 + 最低余额为 %s + 目标账户尚未创建。发送金额应为 %s +费用或更多 + 未知错误 + 无效备注。将不会添加到交易中。 + 备忘录 + 请检查您的网络连接 + 网络费用信息无法获取 您发送 + 从 %s + GAS费限额 + 这是为完成交易或合约所需最大GAS费。GAS费限额可防止在执行交易时发生意外或无限费用。 + GAS费价格 + 这是您愿意为每单位GAS费支付的费用。GAS费价格越高,您的交易处理速度就越快。 + 最大 + 最高金额 + 费用最高可达 + 备忘录: %s + 无效备忘录 + 网络费用覆盖范围 此地址与代币不兼容 + 随机数 + 每笔交易的唯一编号。使用它可以重新发送或取消待处理的交易。 + 输入随机数… + 转账资金不足,手续费和转账金额总额超过了现有余额。 + 总额超过余额 + 为防止安全风险,您的区块链账户需要至少有 %s 的余额。该金额将保留在您的余额中,无法提取。 + 基本保证金 + 您的佣金金额比建议用量高出 %s 倍。请检查并调整您的自定义设置。 + 定制费用高 + 由于 %1$s 网络的特殊性,转移全部余额的手续费较高。为了减少手续费,您可以保留 %2$s。 + 费用更高 + 收款人账户未激活。最低转账金额必须等于或大于免租余额: %1$s。 + 您的账户余额不能低于租金。请在您的账户中保留至少 %1$s 余额或提取所有资金。 + 包含的佣金超过了转账金额,导致出现负值。 + 无效金额 + 最低发送金额为 %1$s。请确保发送后的余额不低于 %2$s。 + 目标账户尚未创建。请更改发送金额。 + 发送金额必须至少为 %s + 离开 %s + 减少 %s + 减至 %s + 请注意,在特定费用设置下,您的交易可能会出现延迟 + 交易可能有延迟 + 由于 %1$s 的限制单笔交易中仅能容纳 %2$s UTXO。这意味着您只能发送 %3$s 或更少。你需要减少数量。 + 交易限制 + 可选 + 请将 QR 码对准正方形扫描。确保您扫描的是 %s 网络地址。 使用固定利率时,您在互换交易时收到的金额将被锁定。这可以保护您免受交易过程中价格波动的影响。 固定利率 浮动利率意味着您最终收到的金额可能会根据您开始和完成互换之间的市场情况略有变化。 浮动利率 利率固定 + 最近 + 收款人 + 无效地址 + 确保收件人地址位于 %s 网络**上,以免丢失资金 + 发送至 + 备忘/目的地址标签是一个独特的 ID,用于区分发送给同一网络中同一收件人的交易:遗漏备忘可能会导致资金丢失** + 备注/目的地标签是加密网络中用于分隔发往同一接收方的交易的代码。 + 注意:遗失备忘录可能导致资金损失。 + 我的钱包 + 比特币交易手续费的衡量方式。它表示交易中每个虚拟字节对应的最小比特币单位(聪)的数量。数值越高,矿工处理交易的速度就越快。 + 聪/vByte + 正在发送... + 点击任意字段即可更改 + 发送 %s + 您正在发送**%1$s**包括网络费用 %2$s。 + 您正在发送**%1$s** 和 %2$s + 您正在发送**%1$s** + 网络费用将通过使用 %1$s 能量来支付 + 支付%1$s 能量将减少网络费用 + 包括网络费用 %1$s + 收款地址 + 交易已成功签署并发送至区块链节点。钱包余额将在稍后更新 + %1$s 是 Tron 网络中的一种资产。要计算费用和进行交易,您需要在账户中存入一些 Tron (TRX)。 + 金额超过余额 + 完成指定地址的交易需要目标地址标签(备忘)。 + 需要目标地址的标签 + 无效金额 + 费用超过余额 + 总金额超过余额 + 您确定要更换接收代币吗?这将重置您之前输入的数据。 + 正在更换代币 + 互换并发送 + 继续互换吗?这将清除您之前的数据。 + 确认转换 + 发送其他任何代币都将导致不可挽回的损失。 + 选择正确的收款人网络 + 选择任何代币接收。您的收款人将准确无误地收到您所选代币—丝滑。 + 收款人将收到 + 收款人 + 应收金额 + 收款人收到 %s + 您确定要取消此次交易吗?您之前的数据将被清除。 + 取消转换 出错了。请再试一次。 + 发送互换代币 + 交易已发送 + 准备扫描您要设置的卡片或指环。 + 忘记钱包 + 这将从应用程序中移除钱包。您可以再次添加该钱包。 简单易用 让您的加密货币安全离线存储。轻薄如信用卡,安全胜过银行金库。 无助记词 一流的硬件钱包 Tangem 冷钱包 + 姓名 + 让您的代币启动 网络费用是处理和确认您在区块链上的交易时所需的小额费用。 要开始质押,您必须通过交易充值 1 TON 来激活您的 TON 账户。资金会保留在您的账户中,因为此步骤仅用于激活账户以进行质押。 激活账户 网络费用已更改。请在继续操作前查看新金额。 网络费用已更新 + 质押金额必须至少为 %s + 根据网络规则,质押金额将四舍五入至 %1$s TRX。 + 由于网络规则的原因,解除质押金额将四舍五入至 %1$s TRX。 年利率 %1$s%% 您的质押奖励将在 5 个周期(约 25 天)后开始发放,在此期间您的委托将由网络注册和统计。 + 提取未质押的 + 质押账户费用 + 质押账户是用于存储质押的 SOL 代币的特殊账户。当您将代币委托给验证节点参与交易验证并获得奖励时,就会创建该账户。创建质押账户会收取少量费用,该费用会在质押完成后退还。 + 年利率 + 年利率 (APR):显示的是不计复利的情况下,您一年内可以获得的利率。您获得的利息不会计入您的本金余额,因此您的收益不会自动增长。 年收益率 年利率 (APY):显示您一年内通过复利计算可获得的总利息。复利是指您获得的利息会添加到您的本金中,因此您还可以获得该利息的利息。 + 年利率 APY + 奖励每日自动累积到您的质押余额中。 奖励将累积到您的质押余额中。已赚取资金: %s + 可用 + 平均奖励率 + 质押如何运作? + %s 预计利润 + 市场地位 + 指标 + 根据 %1$s 网络规则,可从 %2$s提款。以下金额将在解除质押后记入您的账户。 + 最低要求 + 暂无奖励 + 领取奖励 + 领取质押奖励的方式。__ \n可以是自动领取,奖励将直接存入您的地址;也可以是手动领取,您需要创建交易来提取奖励。 + 奖励时间表 + 这是决定质押参与者何时获得奖励的时间表。奖励发放时间可能会因验证节点和网络负载而略有不同。 + 奖励: %s + 质押%s + 解绑期 + 从质押账户提取资金后,您必须等待一段时间才能获得代币。 + 热身期 + 激活参与质押的指定时间。 + 目前没有可用的验证节点。请稍后再试。 + 质押功能不可用 + 网络将收取代币批准费,以验证您是否授权使用您的代币进行质押。 + 使用质押功能即表示您同意提供商的 %1$s 和 %2$s + 已锁定 最高金额: %s + 迁移 + 原生质押 + 目前没有可供质押的活跃验证节点。请稍后再试。 + 在 Cardano 网络上进行质押时,您的全部余额将被使用。系统会额外预留 2 个 ADA,并在您取消质押后返还给您。质押期间,您的 ADA 将保持解锁状态。 + ADA质押详情 + 赚取的奖励将发送到您的钱包,并可立即使用 + Tangem 允许用户质押他们的加密货币 + Tangem 允许用户质押他们的加密货币 + Tangem 允许用户质押他们的加密货币 + Tangem 允许用户质押他们的加密货币 + 质押可让您获得 %1$s。您的质押奖励每天到账。 + 质押可让您获得 %1$s。您的质押奖励每小时到账。 + 质押可让您获得 %1$s。您的质押奖励每月到账。 + 质押可让您获得 %1$s。您的质押奖励每周到账。 + Tangem 允许用户质押他们的加密货币 + 赚取质押奖励 + 您剩余的质押余额太低,无法解除质押。您需要增加质押金额才能达到最低解除质押金额。 + 质押的余额低 + 重新质押需要至少 %1$s %2$s 。请充值您的余额。 + %s不足 + 余额不足,无法进行质押 + 重新质押至少需要 3 个 ADA。请充值您的余额。 + ADA不足 + 质押所需的最低金额必须超过 5 ADA。请充值以开始质押。 + 由于网络状况不佳,目前无法进行质押。请稍后再试。 + 在 %1$s 网络上使用新的验证节点进行质押,会自动将之前质押的所有资金转移到该验证节点上。 + 将您获得的奖励再投资到质押金额中,增加潜在收益。 + 再质押功能允许您将资金从一个验证节点转移到另一个验证节点,而无需解除质押。 + 如果您质押了全部余额,取消质押时需要支付网络费用。我们建议您在钱包中保留少量资金以支付网络费用。 要开始质押,您需要先激活您的 TON 账户。 激活账户 + 要开始在 TON 上进行质押,首先向您自己的地址发送一笔小额交易——这将激活您的钱包。 + 除网络费用外,完成交易可能还需要额外支付最多 0.2 TON 的费用。任何未使用的金额将予以退还。 + 除网络费用外,本次操作还需要 0.2 TON。请充值。 + 需要TON储备 + 根据网络规则,此操作将关闭其他仓位或将其切换为提现状态。 + 仓位状态 + 解锁您的资金即可从质押过程中提款。解锁需要一定时间。 %s。 + 在 21 天的解绑期后您的资金可使用。奖励将与您的解绑资金一起提取。 + %s 解绑期结束后,您的资金即可使用。 + 您现在可以提取资金,资金将立即可用 + 在 Tron 网络中使用新的验证节点进行质押,所有先前质押的资金将自动转移到该验证节点。 + 准备中 + 准备提款 + 再绑定 + 再质押 + 再次质押奖励 + 撤销 + 重新发起投票 + 自动 + 手动 + 每区块 + 每日 + 每 %s + 每天 + 每 %s 天 + 每个 + 每期 + 每阶段 + 每小时 + 每月 + 每周 + 奖励 Solana 的奖励会自动添加到您的质押余额中,无法单独显示。 + 质押已锁定 + 增加质押 + 当质押 %1$s时,你的全部 %2$s 余额已押注。您存入 Tangem 钱包的任何额外 %2$s 资金也将自动进行质押。 + 质押的金额 + 您质押了 %1$s ,并将获得奖励 %2$s + 点击解锁 + 点击解锁或投票 + 点击提款 + 质押%s + 取消抵押 %s + 交易正在处理中!区块链正在进行验证,这可能需要几分钟时间。 + 解除绑定 + 解锁 + 解锁 + 解除质押的金额必须至少为 %s + 金额超过质押余额 + 取消质押 + 取消质押 + 取消质押资产 %s + 验证节点 + 验证节点 + 战略合作伙伴 + 投票 + 投票 + 提款 + 您的质押 + 可妥善保管您的加密资产,同时将私钥保存在您的卡片或指环中。 + 革命性的硬件钱包 + 一个钱包最多可添加 3 张卡片或指环。 + 超级安全备份 + 一款硬件钱包,可同时存储您的比特币、以太坊以及多种其他加密货币—都集成在一张卡片或指环中。 + 数千种代币 + 随时随地,想用就用。无需电线或电池。当您需要使用加密货币时,只需在手机上轻触卡片或指环即可。 + 人人适用的钱包 + 认识Tangem + 提供超过 100 种去中心化服务集成。 + 兼容 Web 3.0 + 至少需要有 %1$s 的转入交易才能继续进行 + 资金不足 批准后,您即允许智能合约在未来的交易中使用您的代币。 + 固定利率 + 网络将收取代币批准费,以验证您是否授权使用您的代币进行兑换。 + 直接在您的钱包中以更优惠的汇率兑换更多代币。 + 新增兑换服务提供商! 还在寻找其他代币?\n尝试搜索或探索其他加密货币! 搜索任何代币,即使它还不在你的列表中。 使用搜索查找所需内容 + 我们提供全天候支持,让您安心无忧,任何问题都能得到帮助。 + 永远在这里 + 多个值得信赖的供应商汇聚一处——在您的钱包中轻松兑换任何资产 + 在 Tangem 直接兑换加密货币\n无需额外转账\n无需将资金转移到其他交易所 + 与我们兑换 + 在您的钱包里兑换 + 零失误,零差错,零盲点——您的交易始终受到保护 + 兑换通过可信服务提供商执行。您的密钥始终保存在您的 Tangem 钱包中。清晰透明,自主保管。 + 坚不可摧的防御 + 您始终掌控一切 + 通过从广泛的可信供应商网络获取的费率,最大限度地提高您的价值,始终选择最好的供应商 + Tangem 会比较多家提供商(包括 DEX 和 CEX)的汇率,并自动选择最优汇率。如果您更喜欢其他提供商,也可以手动选择。 + 无与伦比的价格 + 最优利率 + 操作简便直观,只需轻点几下即可完成代币兑换。 + 跨主流网络和数千种代币进行兑换 0% 稳定币之间互换的手续费 + 简单方便 + 90+ 条区块链\n16,000+ 项资产 + 通过提供商进行互换 您的资产 + 该金额包括:\n- 服务提供商的费用\n- 从交易所向用户地址发送 %s 的网络费用。 + 金额包含:\n• 服务提供商费用\n• 发送网络费用 %1$s 从交易所到用户地址的资金流动。\n\n提供商滑点最高可达 %2$s + 该金额包含服务提供商的费用。 + 该金额包含服务提供商的费用。\n\n服务提供商的滑点最高可达 %s + 信息 + 所有去中心化交易所都要求用户授权,以防止智能合约未经许可访问您的钱包。根据设计,智能合约只有在您授权后才能访问您的代币。通过“解锁”您的代币,您授权 1-inch 智能合约使用这些代币。网络矿工会收到 Gas 费(由您支付)来记录区块链上的此操作。授权后,您可以兑换您的代币。 + 批准 + 费用估算错误。请联系客服反馈。 + 您兑换 + 兑换如此数量的选定代币将对价格产生重大影响,并降低您的收益。 由于流动性低,您收到的资金可能会大大减少。请尝试较小的金额或另一个提供商。 + 价格影响大 + 资金不足 账户余额不足,无法完成此交易。请减少收款金额或增加余额。 + 给予许可 + 兑换 互换... + 您收到 + 选择代币 + 无法使用 此交易流动性不足,请减少金额或选择其他供应商。 交易额过大 我们非常乐意收到您的反馈。 Tangem Pay 现已进入测试阶段 + 无法重命名卡片 卡片已冻结 卡片支付 存款 @@ -384,7 +1585,9 @@ 您的卡片已解冻。 提款 无法在已root的设备上使用 + 可用余额 从主屏幕隐藏 KYC 页面 + Tangem 支付卡 1 增加资金 充值选项 添加到 Google 钱包 @@ -417,10 +1620,14 @@ 分享您的地址或出示二维码 检测到技术问题。请稍后再试或联系技术支持。 目前无法接收 + 更换卡片 + 只允许输入字母和数字。 + 无效字符 显示 显示详情 将您投资组合中的任何资产互换到卡片 卡片详情 + 请稍后再试。 解冻卡片 如果忘记了,请返回应用查看。 您的PIN码 @@ -428,13 +1635,25 @@ 目前无法提款 当前提款交易完成之前,您无法发起新的提款交易或互换交易。 提款进行中 + 改变 + 当前限额 + 我们无法加载您的每日限额。请稍后再试。 + 每日限额不可用 + 您可以随时更改 + 每日限额已设定 + 每日限额 卡片设置 更改PIN码 如果忘记了,请返回应用程序。 + 设置限额从 %s 到 %s + 设置限额 + 数字卡 我明白,我将完全失去对 Tangem Pay 卡及其上所有资金的访问权,且无法挽回 发卡失败 发生技术故障,请点击下方按钮重试。 发生技术故障,请联系技术支持。 + 该功能即将上线 + 您将可以为您的支付账户发行额外的卡。 免费领取您的 Tangem Visa 虚拟卡 获取 Tangem Pay 前往支持页面 @@ -466,9 +1685,20 @@ 将在不透露您的地址和资产的情况下创建一个单独的付款账户 无与伦比的隐私保护 几分钟内即可获得免费的 Tangem Pay 卡 + 支付支持 支付账户 支付账户未同步 无效PIN码:请避免使用连续或重复的密码。 + 更换卡片 + 这将生成一组新的卡片信息。您原有的信息将失效。此操作无法撤销。 + 更换费用 + 无法获得更换费用信息 + 更换您的数字卡 + 通常需要5分钟。极少数情况下,可能需要长达48小时。 + 资金不足,无法更换卡片 + 将USDC存入支付账户以支付发行费用 + 无法支付费用 + 更换您的卡? 我们正在修复技术问题,请稍后再试。 服务暂时不可用 无法显示详细信息。但刷卡支付功能仍然可用。 @@ -478,25 +1708,180 @@ 使用 USDC 进行日常支付 Tangem Pay暂时无法使用。 Tangem Pay + Polygon网络上的 USDC 点击下方按钮恢复访问权限 您的Polygon链上 USDC 余额与您的卡片余额不同,并在购买后 2 个工作日内更新。购物退款的资金不会退还至您的链上余额,也不能提现,但会保留在您的卡片余额中用于购物。 请注意 您的PIN码 + 这是我的钱包 + 余额已隐藏 + 显示余额 + 撤消 + 此操作目前不可用。请稍后再试。 + 目前供应商不支持购买 %s ,但我们正在努力增加更多选项。 + 自定义代币无法执行此操作。 + 您账户中没有资金可以出售。请充值您的账户,以便能够出售资金。 + 您账户资金不足,无法汇款。请充值后再进行汇款。 + 数据正在加载中,可能需要几秒钟时间。请稍后再试。 + 目前供应商不支持兑换 %s ,但我们正在努力增加更多选项。 + 由于缓存原因,显示的余额可能已过时。 + %s 网络上的待处理交易完成后,出售资金即可到账。 + 一旦网络 %s 中的待处理交易完成,即可发送资金 + 目前供应商不支持销售 %s ,但我们正在努力增加更多选项。 + 目前供应商不支持质押 %s ,但我们正在努力增加更多选项。 授权已被撤销。您的资金仍处于收益模式。如需执行操作,请进入收益模式并重新授予权限。 + 可用余额 + 总余额 + 年收入高达 %s + 生成 XPUB + 隐藏 + 您即将从主屏幕隐藏此代币。您可以随时通过“管理代币”页面将其重新添加。 + 隐藏 %s + 隐藏代币 + 质押可让您获得 %1$s 并每 %2$s 天获得奖励 + 质押服务 + %1$s 代币 %%image%% %2$s 网络 + 代币在 %%image%% %1$s 网络 + %s 网络 + %1$s 在 %2$s 网络 + %1$s 在 %%image%% %2$s + %1$s 在 %2$s %%image%% + %1$s (%2$s) 代币是 %3$s 网络的主要代币,只要列表中还有该网络的其他代币,就无法隐藏它。 + 无法隐藏 %s 不适用 显示二维码 + 从 2 月 %2$s-%3$s,兑换另一个代币的服务费为 %1$s 。 + 用Changelly 兑换, %s 费用 + 立即兑换 + 市场趋势 🔥 + 无法购买 + 无法出售 + 无法从 %s兑换 + 无法兑换 + 合约: %s + 您目前还没有任何交易记录。 + 加载交易历史记录失败。\n点击刷新按钮更新信息。 + 多个地址 + 本区块链目前不支持交易历史记录。不过不用担心,我们正在努力!在此期间,您可以在资源管理器中查看。 + 操作 为 %s + 来自 %s + 到: %s + 验证节点: %s + 通知功能已启用,但需要您在设备设置中允许通知才能生效。 + 交易通知 + 最少 %s + 最低交易金额为 %1$s。 + 热门代币在波场网络上的交易费用可能较高。质押 TRX 或许有助于降低交易成本。 + 节省 Tron 网络费用 + 再试一次 + 您扫描了同一张卡。要创建备份钱包,您需要扫描带有编号 %d的卡 + 您扫描的第二张卡片有误,请尝试另一张。 + 你手里拿着的这张卡片,还有另一个编号为 %s的卡片。\n\n这两张卡都可以从这个钱包里取款。 + 一个钱包,两张卡。 + 扫描卡#%s + 创建钱包 + 扫描#%s 备份卡片 + 准备卡片 + Tangem Twin + 此操作不可逆。您将无法访问旧钱包。 + 轻触带有数字 %s 的第二张卡片,操作结束前请勿移除。 + 请稍后再试。如果问题仍然存在,请联系客服。 + 出问题了! + 我们遇到了一个错误。错误代码: %s请联系我们的客服。 + 使用 %s 或者扫描卡片/指环即可访问钱包。 + 连接失败:此 dApp 使用的是 Wallet Connect 1.0 版本,该版本不受支持。请确保 dApp 支持 Wallet Connect 2.0 版本以成功连接。 之前的授权将被撤销,并颁发新的授权。网络将对每次此类操作收取代币批准费。您将在交易记录中看到一笔金额为零的交易,作为撤销授权的证据。 交易金额超过先前授权的金额。\n请更新授权以继续 更新权限 升级到硬件钱包 + 随时了解最新功能和新闻 实时提醒交易、兑换和重要更新。 交易提醒 + 获取新交易通知 + 抢先了解最新促销活动 抢先体验最新功能和专属优惠。 专题报道和新闻更新 + 您想使用推送通知吗? 启用推送通知,即可在资金到账时收到提醒。 不要错过任何一笔交易 + 添加钱包 如果您在没有备份的情况下删除此钱包,您将永久失去对您的资金的访问权限。 + 您确定要忘记这个钱包吗? + 发生错误,请扫描您的卡片或使用指环登录。 + 此钱包已保存,您可以添加另一个钱包 + 名称为 %s 的钱包已经存在 + 钱包名称 + 重命名钱包 + 全部解锁 + 解锁所有 %s + 已通过反洗钱(AML)验证 + 可用 + 已屏蔽 + 债务 + 限制 + 其他(非一次性密码) + 单笔交易 + 总计 + + 可用%d天 + + 可用余额是指实际可用的资金,已考虑待处理交易、冻结金额和借方余额,以防止透支。 + 可用日期至 %1$s + 余额与限额 + 缺少付款账户信息。请联系客服。 + 无法加载代币信息 + 信息正在加载,请稍候。 + 为控制成本、提高安全性和管理风险,需要设定限额。在此期间,您可以使用 %1$s 在商店刷卡消费,也可以使用 %2$s 进行其他交易,如订阅或借贷。 + 访问代码将用于管理您的付款账户,防止未经授权的访问。 + 访问码 + 账户激活 + 请选择您注册时使用的钱包,以签署在区块链上创建帐户的交易。 + 取消激活 + 您确定要退出吗?您可以稍后从上次中断的地方继续。 + 这不会太久。我们正在为您设置账户。 + 不会太久。我们正在完成激活工作。 + 一切准备就绪! + 其他钱包 + 设置一个4位数的验证码。\n该验证码将用于支付。 + PIN码 + 创建 PIN 码 + PIN码未被接受。请重试或使用其他密码。 + 无效 PIN 码:避免使用连续或重复密码 + 一切就绪! + 准备好 Tangem 卡并点击批准 + 准备 Tangem 钱包 + 您可以在第三方网站上完成连接,然后返回 Tangem 应用。 + 前往网站 + 钱包连接 + 选择钱包 + 继续激活 + 让我们继续设置您的帐户。 + 欢迎回来! + 开始激活 + 请按照以下步骤设置您的账户。 + 欢迎! + 区块链金额 + 货币代码 + 日期 + 错误代码 + 交易详情 + 商户类别代码 + 商户城市 + 商户国家代码 + 商户名称 + 请求 ID + 状态 + 交易 + 交易金额 + 交易哈希 + 交易请求 + 交易状态 + 类型 + 对该笔交易提出异议 + 解锁 + 扫描您的卡以解锁访问权限 + 需要解锁 选择您的钱包类型 扫描您的 Tangem 卡或指环以恢复卡号或从其他钱包导入卡号。 创建硬件钱包 @@ -504,11 +1889,100 @@ 导入助记词 在手机上恢复您的钱包或从其他应用程序导入—方便,但安全性不如 Tangem 卡。 选择哪个? + 区块链目前无法访问,请稍后再试。 + 扫描卡或指环 + 此钱包之前已被激活。__ \n如果并非您激活的,请联系客服。__ \n Tangem 从不出售带有预先生成访问码的钱包。 + 请求签署信息。\n\n%s + Dapp %1$s请求\n签署BNB交易。\n\n%2$s + 交易订单 %1$s\n价格: %2$s\n应收金额: %3$s\n应付金额: %4$s + 交易详情:\n从: %1$s\n到: %2$s\n数量: %3$s + 剪贴板包含 WalletConnect 代码。使用复制的值或扫描 QR 码 + 请求创建交易 %1$s\n%2$s\n\n数量: %3$s\n费用: %4$s\n总计: %5$s\n余额: %6$s + 无法发送交易。资金不足。 + WalletConnect会话建立失败。请稍后再试。 + 并非所有代币都已添加到您的列表中。请先添加,然后再试一次。缺少的代币:\n + 信息签名失败。\n请重试 + WalletConnect 会话建立失败:超时错误。请稍后重试。 + 会话请求包含 WalletConnect 连接不支持的区块链。不支持的区块链:\n + 由于技术原因,无法与此 DApp 建立连接。 + 我们遇到了未知错误。错误信息: %s。如果问题仍然存在,请随时联系我们的支持团队。 + 在 Tangem App 中选择了错误的卡片或指环 + 无法从 DApp 数据创建交易。代码: %s + 我们遇到了未知错误。错误代码: %d。如果问题仍然存在,请随时联系我们的支持团队。 多部分交易 为了顺利完成交易,您的交易将被拆分成多个部分。您需要多次刷卡才能完成交易。 + 没有打开的 WalletConnect 会话 + 糟糕,没有会话。 + WalletConnect会话配对失败: %1$s + 从剪贴板粘贴 + 消息 %1$s:\n%2$s + 请求开始会话\n%1$s\n\n网络: %2$s\n\n网址: %3$s + 操作无法完成。 您已经使用此参数建立了 WalletConnect 会话。 + 扫描新代码 + 此卡不能用于建立 WalletConnect 会话 + 不支持该网络。请选择其他网络。 + 选择网络 交易正在处理中。请多次用卡轻触以完成交易。 交易进行中 + WalletConnect 会话 + 连接到 dApps + WalletConnect + 连接可能需要几秒钟。 创建 Tangem 钱包 + 从 %s + 购买 Tangem 钱包——一款可离线安全存储您的私钥的实体设备。 + 硬件钱包 + 只需几秒钟就能在手机上创建一个安全的钱包。 + 移动钱包 + 选哪个? + 已经在使用 Tangem 钱包了吗? + 立即扫描 + 选择一种钱包设置方法 + 想买一个 Tangem 钱包吗? + 立即购买 + 通过 Google 云端硬盘备份恢复现有钱包 + 从 Google 云端硬盘导入 + 添加现有钱包 + 可离线安全存储您的私钥的物理设备。 + 扫描 Tangem 钱包 + 使用恢复短语导入现有钱包。 + 导入钱包 + 输入恢复短语 + 您的钱包已成功导入。 + 导入钱包 + 导入完成 + 导入钱包 + %s 市场价格 + 过去24小时 + %s 网络 + 地址已复制到剪贴板 + 无网络连接 + 现在获取,10% 折扣 + 访问超过 13,000 种加密货币。一键即可购买、出售、兑换和质押。__ \n最多可绑定三张银行卡作为备用。 + 探索 Tangem 钱包 + 此访问码用于保护您的钱包,并用于登录和签署交易。 + 设置/更改访问代码 + 更改访问代码 + 接收钱包交易推送通知。 + 目前华为设备可能无法正常使用推送通知功能。我们正在积极寻找解决方案,并将在即将发布的更新中修复此问题。感谢您的理解! + 交易通知 + 设置访问代码 + 钱包设置 + Tangem + 使用 %s 或者扫描卡片/指环来解锁访问钱包。 + 许可审批程序目前正在进行中,并将很快完成。 + 审批中 + 激活未成功完成。这可能是由于 NFC 问题或轻触操作不正确造成的。请联系我们的支持团队寻求帮助。 + 激活错误 + 2024年12月3日,BEP-2网络经网络开发商决定停用,不再提供支持。 + BNB信标链关闭 + 请存入一些 %1$s 用于支付网络费用 + 资金不足以支付网络费用 + 可以做得更好 + 喜欢 + 好的,明白了! + 真酷! + 刷新 开始迁移 复制 要继续使用您的资金,请根据 Clore 官方指南开始迁移。 @@ -522,17 +1996,179 @@ 签署 签名 Clore 网络迁移 + 您目前处于演示模式 + 演示模式已激活 + 您扫描的卡是一张开发者卡。请不要用它来创建钱包。 + 不适用于用户! + %1$s 网络需要存入一笔基本保证金。如果您的账户余额低于 %2$s账户将被停用,剩余资金将被销毁。 + 网络需要基本保证金 + %s 交易完成后即可进行兑换 + 您有活跃的交易 + 兑换审批正在进行中,很快完成。 + 审批进行中 + 最低兑换金额为 %1$s。请确保兑换后的剩余余额不少于 %2$s。 + 您的投资组合中没有 %s 可以兑换的代币。请添加其他代币以启用兑换。 + 未添加兼容代币 + 要进行交易,您需要存入一些 %1$s %2$s + 无法覆盖 %s 费用 + 收到的金额必须至少 %s + 这可能是因为供应商目前无法为您更换所选交易对。请稍等片刻后重试。(代码) %s) + 所选交易对暂时不可用 + 部分服务提供商未经英国金融行为监管局授权,请避免与其进行交易。 + FCA警告清单 + 服务暂时不可用 + 待兑换的代币数量不得超过 %s + 兑换金额必须至少为 %s 此交易对不支持互换。请选择其他代币重试。 不支持的互换对 + 请更改兑换金额 + 这张卡可能是生产样品或伪造品。 + 真实性检查失败 + 关联 + 您必须先将此代币关联到您的 Hedera 账户才能收到。关联费 ~%1$s %2$s + 您必须先将此代币与您的 Hedera 账户关联才能收到它。 + 关联您的代币 + %s不够。请为您的 Hedera 账户充值以关联此代币 + 您确定要取消交易吗?取消后您将无法再次尝试交易。 + 您金额为 %1$s %2$s 的交易未完成。您可以再次尝试完成交易。 + 您有未完成的交易 + 上次更新时间是 %s + 该卡上只剩下 %s 签名。您必须提取所有资金。 + 签名数量少 + 不同网络的代币可能有不同的地址。转账时,请仔细检查您的地址是否与网络相匹配。 + MATIC 正在迁移到 POL。但是,目前尚未设定迁移截止日期,MATIC 也尚未被弃用。您可以继续安全地使用 MATIC 代币,或者将其替换为 POL 代币。 + MATIC 到 POL 的迁移 + + 使用您的卡片或指环获取%d网络地址 + + 部分地址缺失 + 目前网络无法连接,请稍后再试。 + 网络无法访问 + 给您的钱包充值 + 您的钱包尚未备份。立即备份以保护您的资产。 + 缺少备份 + 此卡曾用于交易。如果是从不可信来源收到的,请考虑提取所有资金。如果是您的卡,则无需采取任何措施。 + 卡片已签署交易 + 您的评价激励我们不断改进 Tangem Wallet。 + 喜欢 Tangem 吗? + 您必须先关联您的代币才能接收代币。 + 在接收代币之前,您必须为代币开通Trustline + 需支付网络租金 + 需要采取行动 + 您在创建钱包后的7天内是否通过应用程序联系过客服?如果是,或者如果您不确定,请点击“是”并按照说明操作。 + 谢谢!一切就绪!无需其他操作。 + 您现在将被重定向到 Tangem 官方网站。请阅读并按照网站上的说明进行操作。 + 您是否曾通过此应用程序直接联系过 Tangem 支持团队? + 强制性安全更新 + %1$s 是 %2$s 网络中的一种资产。要进行 %3$s 交易,必须存入一些 %4$s (%5$s) 以支付网络费用。 + %1$s 不足以支付网络费用 + Solana网络拥堵。如果您的交易在2分钟内未处理完毕,请重新进行交易。 + Solana 网络警报 + Solana 网络每 2 天收取 %1$s 的租金。付不起租金的账户将被清除出网络。将您的账户存入 %2$s 以上即可免费使用。 + 向下滑动刷新页面或稍后再试。 + 部分网络无法访问。 + 部分代币余额无法更新 + 没有足够的 %s,请为您的 XLM 账户充值,开通信托线。 + 这是一张测试网卡片。它不能处理交易,只能用于测试和开发目的。 + 仅供测试 + 余额可能已过期,请刷新页面。 + %1$s不足。为 %2$s 帐户充值以关联此代币 + 启用 Trustline + 必须启用Trustline才能接收此代币。网络需要 %1$s %2$s 储备。 + 需要Trustline 所需区块链 %s 尚未添加到您的投资组合中。请先添加,然后再进行连接。 将区块链添加到投资组合 + 恶意域名 + 未知域名 + 仍然连接 + 超时错误。请稍后再试。 + 建立 WalletConnect 连接失败 + 此域名无法验证。请仔细检查请求后再批准。 要继续,请将您的 dApp 会话重新连接到所需的区块链网络 %s。 区块链网络未连接 检查您的区块链连接 请求超时 + 请返回浏览器并通过 WalletConnect 重新连接。 + Wallet Connect 会话已断开连接。 + 确定签名 + 错误代码: %s如果问题仍然存在,请随时联系我们的客服。 + 如果问题仍然存在,请随时联系我们的客服。 + 我们遇到了未知错误 + Tangem Wallet 目前不支持 %s。 + 不支持的 dApp + 错误代码:8 005.如果问题仍然存在,请随时联系我们的技术支持。 + 我们遇到了未知错误 该区块链 %s Tangem Wallet 不支持,无法连接。 不支持的区块链 + Tangem 目前不支持 %s所要求的网络。 + 不支持的网络 + 该域名已通过验证检查,被认为是安全、信誉良好且不存在已知威胁或可疑活动。 + 已验证域名 + 在应用程序中选择了错误的卡片或指环 + 我们遇到了一些问题。 + 所有dApp已断开连接 + 允许支出 通过批准,您允许 dApp 或智能合约在未来的交易中使用代币。 + 地址 + 连接 + 加载中 + 网络 + 网络 + 无限 + 钱包 + 已连接的App + 连接的网络 + 已连接到 %1$s + 查看您的钱包余额和活动 + 在未通知您的情况下签署交易 + 请求批准交易 + 将无法 + 希望 + 连接请求 + 连接 + 内容 + 复制数据 + 自定义津贴 + dApp已断开连接 + 断开所有连接 + 所有 dApp 会话都将断开。您的钱包将不再与任何 dApp 关联。 + 断开所有去dApp + 请尝试使用新的 URI 再次配对。 + 无效的 dApp 域名 + %s 未指定任何区块链——既非必需也非可选。\n请确保您使用了正确的 URI + 无网络 + 请生成一个新的URI并再次尝试连接。 + 连接提议已过期 + 预估钱包变化 + 交易无法模拟,请谨慎操作。 + 对%s不支持估算 + 建议 %s + 充值余额以支付网络费用 + 不足 %1$s + 恶意交易 + 将 %s 网络添加到您的钱包投资组合中 + 该钱包没有所需网络。 + 新连接 + 将您的钱包连接到不同的dApp应用程序 + 无会话 + 未检测到钱包变更 + 已检测到潜在风险或恶意行为。连接或签署交易可能会导致资金损失。 + 已知安全风险 + 打开 Web3 应用并选择 WalletConnect 选项。 + 请求来自 + 确定签名 + 签名类型 + dApp 连接至少需要一个网络 + 指定所选网络 + 签名成功 + + 交易请求 + 交易请求 + 无金额限制 + 确保每次配对尝试都使用新的、唯一的URI。 + URI 已被使用 + WalletConnect + 可疑交易 已经拥有 Tangem 钱包? 数千种资产 顶级硬件钱包 @@ -548,6 +2184,20 @@ 其他方法 使用 Tangem 硬件钱包 了解更多并购买 + 放弃 + 您的备份已中断。是否要恢复? + 是的,恢复 + 放弃 + 如果您现在放弃备份,就必须将设备重置为出厂设置,重新开始 + 恢复备份 + 这是不可逆转的行为 + 用 %s登录 + 扫描卡片或指环 + 使用 %s 或扫描卡片或指环访问应用程序 + 欢迎回来! + 不,全部发送 + 减少 %s XTZ + 为避免下次充值时支付更高的手续费,请按 %s XTZ减少充值金额。 启用收益模式后,所有未来充值到此地址的资金都将转入 Aave。您仍然可以自由管理您的资金。 你的 %s 提供给 Aave 供应 %1$s %2$s 到 Aave diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index b40fb7272c..5bfd090643 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -281,11 +281,13 @@ Delete Disable Disabled + Disabling Disconnect Done Edit Enable Enabled + Enabling Error Top-up network fee Swap @@ -331,6 +333,7 @@ %d minutes ago month + More Network fee Amount sent will be reduced by %1$s (%2$s) to cover the selected fee level @@ -376,6 +379,7 @@ Select action Sell Send + Send: Failed to send transaction The server is not available, please try again later Share @@ -1007,6 +1011,11 @@ Please repeat the operation. The card will be reset to factory settings. Activation error Add tokens + + Your wallet contains %d token. To continue, please sync your addresses. + Your wallet contains %d tokens. To continue, please sync your addresses. + + Sync your wallet You\'ve added one backup card or ring. Once backup is finalized, you can\'t add more devices. If you have one more card or ring, add it now. Do you want to continue? The backup is partially complete and can\'t be quit now. A passphrase is an optional security feature that adds a word or phrase to your recovery phrase, creating a new set of wallet addresses for extra protection. @@ -2234,6 +2243,7 @@ The fee will be deducted, and your assets will be resupplied. To continue generating yield, approval is required. Confirm approval + Average APY %1$s%% Your funds are currently supplied to the Aave protocol, but you can manage them at any time. Your %s is supplied to Aave Unable to load chart... @@ -2307,6 +2317,7 @@ Interest accrues automatically Yield Mode Enabling Yield Mode + Yield Mode • %1$s%% APY Yield Mode Yield Mode contract deploy Yield Mode enabled From 53b02cfb1014660ee78bbc7b1693e5006c3722fa Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Apr 2026 03:02:00 -0700 Subject: [PATCH 098/206] Updated on 2026-08-14 --- .../com/tangem/datasource/di/MoshiModule.kt | 2 +- .../visa/DefaultTangemPayReissueCardStore.kt | 2 +- .../local/visa/TangemPayReissueCardStore.kt | 2 +- .../entity/PaymentAccountStatusValueDM.kt | 22 ++- .../utils/MoshiDataStoreSerializer.kt | 7 +- .../pay/DefaultTangemPayEligibilityManager.kt | 2 +- .../PaymentAccountStatusValueDMConverter.kt | 91 ++++++----- .../tangem/data/pay/di/TangemPayDataModule.kt | 2 + .../DefaultPaymentAccountStatusFetcher.kt | 52 +++---- .../repository/DefaultOnboardingRepository.kt | 6 +- .../DefaultReissueCardRepository.kt | 2 +- .../pay/store/PaymentAccountStatusesStore.kt | 2 + .../IsAccountsModeEnabledUseCaseTest.kt | 24 --- .../account/PaymentAccountStatusValue.kt | 61 +------- .../tangem/domain/models/pay/TangemPayCard.kt | 25 +++ .../domain/models/pay/TangemPayCardLimit.kt | 49 ++++++ .../models/pay/TangemPayCardLimitData.kt | 10 ++ .../{ => pay}/TangemPayEligibilityType.kt | 2 +- .../{ => pay}/TangemPayReissueCardFee.kt | 2 +- .../tangem/domain/pay/model/CustomerInfo.kt | 1 + .../domain/pay/model/TangemPayCardLimit.kt | 32 ---- .../pay/repository/OnboardingRepository.kt | 2 +- .../TangemPayReissueCardRepository.kt | 2 +- ...ymentAccountCryptoCurrencyStatusUseCase.kt | 1 - .../destination/model/SendDestinationModel.kt | 1 - .../feature/swap/domain/SwapInteractorImpl.kt | 1 - .../converter/ChooseTokenListItemConverter.kt | 1 - .../tangempay/model/TangemPayCardPageModel.kt | 145 ++++++++---------- .../domain/GetMultiWalletWarningsFactory.kt | 1 - .../domain/GetWalletNotificationsFactory.kt | 1 - .../converter/TangemPayMainBlockConverter.kt | 79 ++++------ 31 files changed, 286 insertions(+), 346 deletions(-) create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimit.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimitData.kt rename domain/models/src/main/kotlin/com/tangem/domain/models/{ => pay}/TangemPayEligibilityType.kt (89%) rename domain/models/src/main/kotlin/com/tangem/domain/models/{ => pay}/TangemPayReissueCardFee.kt (77%) delete mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCardLimit.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt index effeecade9..d4a7ccf033 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/MoshiModule.kt @@ -51,7 +51,7 @@ class MoshiModule { .withSubtype(PaymentAccountStatusValueDM.NotCreated::class.java, "not_created") .withSubtype(PaymentAccountStatusValueDM.UnderReview::class.java, "kyc_status") .withSubtype(PaymentAccountStatusValueDM.IssuingCard::class.java, "issuing_card") - .withSubtype(PaymentAccountStatusValueDM.ActiveCard::class.java, "active_card") + .withSubtype(PaymentAccountStatusValueDM.ActiveAccount::class.java, "active_account") .withSubtype(PaymentAccountStatusValueDM.CardIssueFailed::class.java, "card_issue_failed"), ) .add( diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt index d179f07eb3..666b4e790c 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt @@ -1,7 +1,7 @@ package com.tangem.datasource.local.visa import com.tangem.datasource.local.datastore.RuntimeDataStore -import com.tangem.domain.models.TangemPayReissueCardFee +import com.tangem.domain.models.pay.TangemPayReissueCardFee import com.tangem.domain.models.wallet.UserWalletId internal class DefaultTangemPayReissueCardStore( diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt index bc7825051f..0925c1ea31 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/TangemPayReissueCardStore.kt @@ -1,6 +1,6 @@ package com.tangem.datasource.local.visa -import com.tangem.domain.models.TangemPayReissueCardFee +import com.tangem.domain.models.pay.TangemPayReissueCardFee import com.tangem.domain.models.wallet.UserWalletId interface TangemPayReissueCardStore { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 98bfec8c62..31f6c44adf 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -4,6 +4,7 @@ package com.tangem.datasource.local.visa.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.serialization.SerializedBigDecimal import dev.onenowy.moshipolymorphicadapter.PolymorphicAdapterType import dev.onenowy.moshipolymorphicadapter.annotations.NameLabel import java.math.BigDecimal @@ -32,18 +33,14 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "issuing_card") val marker: Boolean = true, ) : PaymentAccountStatusValueDM - @NameLabel("active_card") - data class ActiveCard( - @Json(name = "active_card") val isLocked: Boolean, + @NameLabel("active_account") + data class ActiveAccount( @Json(name = "customer_id") val customerId: String, - @Json(name = "card_id") val cardId: String, - @Json(name = "last_four_digits") val lastFourDigits: String, @Json(name = "currency_code") val currencyCode: String, @Json(name = "deposit_address") val depositAddress: String?, - @Json(name = "is_pin_set") val isPinSet: Boolean, @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, - @Json(name = "display_name") val displayName: String?, + @Json(name = "cards") val cards: List, ) : PaymentAccountStatusValueDM @NameLabel("card_issue_failed") @@ -66,4 +63,15 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "token_contract_address") val tokenContractAddress: String, @Json(name = "balance") val balance: BigDecimal, ) + + @JsonClass(generateAdapter = true) + data class TangemPayCard( + @Json(name = "id") val id: String, + @Json(name = "has_pin_code") val hasPinCode: Boolean, + @Json(name = "display_name") val displayName: String?, + @Json(name = "actual_daily_limit") val actualDailyLimit: SerializedBigDecimal?, + @Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?, + @Json(name = "is_frozen") val isFrozen: Boolean, + @Json(name = "last_digits") val lastDigits: String, + ) } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt index c76773424f..7dae75ccfe 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/MoshiDataStoreSerializer.kt @@ -1,5 +1,6 @@ package com.tangem.datasource.utils +import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi @@ -34,7 +35,11 @@ class MoshiDataStoreSerializer( override suspend fun readFrom(input: InputStream): T { return input.bufferedReader().use { reader -> - adapter.fromJson(reader.readText()) ?: defaultValue + try { + adapter.fromJson(reader.readText()) ?: defaultValue + } catch (e: Exception) { + throw CorruptionException("Failed to deserialize data", e) + } } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index fb9bd81fda..029791dec1 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -2,7 +2,7 @@ package com.tangem.data.pay import com.tangem.common.card.FirmwareVersion import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.TangemPayEligibilityType +import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index cfa7822955..8fbc87cb6f 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -1,11 +1,15 @@ package com.tangem.data.pay.converter -import com.tangem.data.pay.entity.TangemPayCurrencyFactory import arrow.core.getOrElse +import com.tangem.data.pay.entity.TangemPayCurrencyFactory import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardLimit +import com.tangem.domain.models.pay.TangemPayCardLimitData +import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.models.wallet.UserWalletId import javax.inject.Inject import javax.inject.Singleton @@ -31,29 +35,23 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( customerId = value.customerId, ) is PaymentAccountStatusValue.IssuingCard -> PaymentAccountStatusValueDM.IssuingCard() - is PaymentAccountStatusValue.Locked -> PaymentAccountStatusValueDM.ActiveCard( - isLocked = true, + is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveAccount( customerId = value.customerId, - cardId = value.cardId, - lastFourDigits = value.lastFourDigits, currencyCode = value.currencyCode, depositAddress = value.depositAddress, - isPinSet = value.isPinSet, fiatBalance = value.fiatBalance.toDM(), cryptoBalance = value.cryptoBalance.toDM(), - displayName = value.displayName?.value, - ) - is PaymentAccountStatusValue.Loaded -> PaymentAccountStatusValueDM.ActiveCard( - isLocked = false, - customerId = value.customerId, - cardId = value.cardId, - lastFourDigits = value.lastFourDigits, - currencyCode = value.currencyCode, - depositAddress = value.depositAddress, - isPinSet = value.isPinSet, - fiatBalance = value.fiatBalance.toDM(), - cryptoBalance = value.cryptoBalance.toDM(), - displayName = value.displayName?.value, + cards = value.cards.map { card -> + PaymentAccountStatusValueDM.TangemPayCard( + id = card.id, + hasPinCode = card.hasPinCode, + displayName = card.displayName?.value, + actualDailyLimit = card.limit?.actualCardLimit?.amount, + adminDailyLimit = card.limit?.adminCardLimit?.amount, + isFrozen = card.isFrozen, + lastDigits = card.lastDigits, + ) + }, ) is PaymentAccountStatusValue.Error.CardIssueFailed -> PaymentAccountStatusValueDM.CardIssueFailed( customerId = value.customerId, @@ -76,35 +74,32 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( is PaymentAccountStatusValueDM.IssuingCard -> PaymentAccountStatusValue.IssuingCard( source = StatusSource.CACHE, ) - is PaymentAccountStatusValueDM.ActiveCard -> if (value.isLocked) { - PaymentAccountStatusValue.Locked( - source = StatusSource.CACHE, - customerId = value.customerId, - cardId = value.cardId, - lastFourDigits = value.lastFourDigits, - currencyCode = value.currencyCode, - depositAddress = value.depositAddress, - isPinSet = value.isPinSet, - fiatBalance = value.fiatBalance.toDomain(), - cryptoBalance = value.cryptoBalance.toDomain(), - cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), - displayName = value.displayName?.let { CardDisplayName(it).getOrElse { null } }, - ) - } else { - PaymentAccountStatusValue.Loaded( - source = StatusSource.CACHE, - customerId = value.customerId, - cardId = value.cardId, - lastFourDigits = value.lastFourDigits, - currencyCode = value.currencyCode, - depositAddress = value.depositAddress, - isPinSet = value.isPinSet, - fiatBalance = value.fiatBalance.toDomain(), - cryptoBalance = value.cryptoBalance.toDomain(), - cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), - displayName = value.displayName?.let { CardDisplayName(it).getOrElse { null } }, - ) - } + is PaymentAccountStatusValueDM.ActiveAccount -> PaymentAccountStatusValue.Loaded( + source = StatusSource.CACHE, + customerId = value.customerId, + currencyCode = value.currencyCode, + depositAddress = value.depositAddress, + fiatBalance = value.fiatBalance.toDomain(), + cryptoBalance = value.cryptoBalance.toDomain(), + cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), + cards = value.cards.map { card -> + TangemPayCard( + id = card.id, + hasPinCode = card.hasPinCode, + displayName = card.displayName?.let { CardDisplayName(it).getOrElse { null } }, + limit = TangemPayCardLimitData( + actualCardLimit = card.actualDailyLimit?.let { limit -> + TangemPayCardLimit(limit, TangemPayCardLimitPeriod.DAY) + }, + adminCardLimit = card.adminDailyLimit?.let { limit -> + TangemPayCardLimit(limit, TangemPayCardLimitPeriod.DAY) + }, + ), + isFrozen = card.isFrozen, + lastDigits = card.lastDigits, + ) + }, + ) is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( source = StatusSource.CACHE, kycStatus = value.kycStatus, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 7d55c0625d..2c3e46bd19 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -2,6 +2,7 @@ package com.tangem.data.pay.di import android.content.Context import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile import com.squareup.moshi.Moshi import com.tangem.data.pay.DefaultTangemPayCryptoCurrencyFactory @@ -129,6 +130,7 @@ internal interface TangemPayDataModule { types = mapWithStringKeyTypes(), defaultValue = emptyMap(), ), + corruptionHandler = ReplaceFileCorruptionHandler { emptyMap() }, produceFile = { context.dataStoreFile(fileName = "payment_account_statuses") }, scope = scope, ), diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 1cb938336b..220bdc93a1 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -9,6 +9,8 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardLimitData import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.CustomerInfo @@ -277,34 +279,28 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( customerId: String, ): PaymentAccountStatusValue { val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId) - return when (productInstance.frozenState) { - TangemPayCardFrozenState.Frozen -> PaymentAccountStatusValue.Locked( - source = StatusSource.ACTUAL, - customerId = customerId, - cardId = productInstance.cardId, - lastFourDigits = cardInfo.lastFourDigits, - currencyCode = cardInfo.currencyCode, - depositAddress = cardInfo.depositAddress, - isPinSet = cardInfo.isPinSet, - fiatBalance = cardInfo.fiatBalance, - cryptoBalance = cardInfo.cryptoBalance, - cryptoCurrency = cryptoCurrency, - displayName = productInstance.displayName, - ) - else -> PaymentAccountStatusValue.Loaded( - source = StatusSource.ACTUAL, - customerId = customerId, - cardId = productInstance.cardId, - lastFourDigits = cardInfo.lastFourDigits, - currencyCode = cardInfo.currencyCode, - depositAddress = cardInfo.depositAddress, - isPinSet = cardInfo.isPinSet, - fiatBalance = cardInfo.fiatBalance, - cryptoBalance = cardInfo.cryptoBalance, - cryptoCurrency = cryptoCurrency, - displayName = productInstance.displayName, - ) - } + return PaymentAccountStatusValue.Loaded( + source = StatusSource.ACTUAL, + customerId = customerId, + currencyCode = cardInfo.currencyCode, + depositAddress = cardInfo.depositAddress, + fiatBalance = cardInfo.fiatBalance, + cryptoBalance = cardInfo.cryptoBalance, + cryptoCurrency = cryptoCurrency, + cards = listOf( + TangemPayCard( + id = productInstance.cardId, + hasPinCode = cardInfo.isPinSet, + displayName = productInstance.displayName, + limit = TangemPayCardLimitData( + actualCardLimit = productInstance.actualCardLimit, + adminCardLimit = productInstance.adminCardLimit, + ), + isFrozen = productInstance.frozenState is TangemPayCardFrozenState.Frozen, + lastDigits = cardInfo.lastFourDigits, + ), + ), + ) } private fun VisaApiError.mapToPaymentAccountStatus(): PaymentAccountStatusValue { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index aa7f791149..9b3188f36b 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -15,7 +15,7 @@ import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.TangemPayEligibilityType +import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus @@ -25,8 +25,8 @@ import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.CustomerInfo.CardInfo import com.tangem.domain.pay.model.CustomerInfo.ProductInstance -import com.tangem.domain.pay.model.TangemPayCardLimit -import com.tangem.domain.pay.model.TangemPayCardLimitPeriod +import com.tangem.domain.models.pay.TangemPayCardLimit +import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.error.VisaApiError diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt index abffaa87fc..b6ee3b4b76 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt @@ -8,7 +8,7 @@ import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.ReissueCardRequest import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.local.visa.TangemPayReissueCardStore -import com.tangem.domain.models.TangemPayReissueCardFee +import com.tangem.domain.models.pay.TangemPayReissueCardFee import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.TangemPayReissueOrderInfo diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt index 62accbccf7..c39190f6d8 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/store/PaymentAccountStatusesStore.kt @@ -10,6 +10,7 @@ import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.wallet.UserWalletId import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -45,6 +46,7 @@ internal class PaymentAccountStatusesStore( }, ) } catch (e: Exception) { + runSuspendCatching { persistenceDataStore.updateData { emptyMap() } } TangemLogger.e("Error while loading cached payment account statuses", e) } } diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt index 49a4a0bd50..df60423785 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCaseTest.kt @@ -78,18 +78,6 @@ class IsAccountsModeEnabledUseCaseTest { Truth.assertThat(actual).isTrue() } - @Test - fun `returns true when payment account is Locked`() = runTest { - val statusList = createAccountStatusList( - statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk())), - ) - every { multiAccountStatusListSupplier.invoke() } returns flowOf(listOf(statusList)) - - val actual = useCase.invoke().first() - - Truth.assertThat(actual).isTrue() - } - @Test fun `returns false when payment account is NotCreated`() = runTest { val statusList = createAccountStatusList( @@ -210,18 +198,6 @@ class IsAccountsModeEnabledUseCaseTest { Truth.assertThat(actual).isTrue() } - @Test - fun `returns true when payment account is Locked`() = runTest { - val statusList = createAccountStatusList( - statuses = listOf(mockCryptoPortfolio(), mockPayment(mockk())), - ) - coEvery { multiAccountStatusListSupplier.getSyncOrNull(Unit, any()) } returns listOf(statusList) - - val actual = useCase.invokeSync() - - Truth.assertThat(actual).isTrue() - } - @Test fun `returns false when payment account is NotCreated`() = runTest { val statusList = createAccountStatusList( diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index 8e74069ff6..6187861ae1 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.serialization.SerializedBigDecimal import kotlinx.serialization.Serializable import java.math.BigDecimal @@ -28,7 +29,6 @@ sealed class PaymentAccountStatusValue { is UnderReview, -> TotalFiatBalance.Loaded(amount = SerializedBigDecimal.ZERO, source = source) is Loading -> TotalFiatBalance.Loading - is Locked -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source) is Loaded -> TotalFiatBalance.Loaded(amount = fiatBalance.availableBalance, source = source) } @@ -41,7 +41,6 @@ sealed class PaymentAccountStatusValue { return when (this) { is IssuingCard -> copy(source = source) is Loaded -> copy(source = source) - is Locked -> copy(source = source) is UnderReview -> copy(source = source) is Loading, is NotCreated, @@ -84,81 +83,27 @@ sealed class PaymentAccountStatusValue { @Serializable data class IssuingCard(override val source: StatusSource) : PaymentAccountStatusValue() - /** - * Represents a state where the payment account is locked. - * - * @property source The source of the status information. - * @property customerId The unique identifier of the customer. - * @property cardId The unique identifier of the card. - * @property lastFourDigits The last four digits of the card number. - * @property currencyCode The code of the currency. - * @property depositAddress The address for deposits, if available. - * @property isPinSet Indicates if the PIN is set for the card. - * @property fiatBalance The fiat balance details. - * @property cryptoBalance The crypto balance details. - */ - @Serializable - data class Locked( - override val source: StatusSource, - val customerId: String, - val cardId: String, - val lastFourDigits: String, - val currencyCode: String, - val depositAddress: String?, - val isPinSet: Boolean, - val fiatBalance: FiatBalance, - val cryptoBalance: CryptoBalance, - val cryptoCurrency: CryptoCurrency.Token, - val displayName: CardDisplayName?, - ) : PaymentAccountStatusValue() { - val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( - currency = cryptoCurrency, - value = CryptoCurrencyStatus.Loaded( - amount = cryptoBalance.balance, - fiatAmount = fiatBalance.availableBalance, - fiatRate = BigDecimal.ONE, - priceChange = BigDecimal.ZERO, - networkAddress = NetworkAddress.Single( - defaultAddress = NetworkAddress.Address( - type = NetworkAddress.Address.Type.Primary, - value = cryptoBalance.depositAddress, - ), - ), - sources = CryptoCurrencyStatus.Sources(), - pendingTransactions = emptySet(), - stakingBalance = null, - yieldSupplyStatus = null, - hasCurrentNetworkTransactions = false, - ), - ) - } - /** * Represents a state where the payment account is successfully loaded with complete information. * * @property source The source of the status information. * @property customerId The unique identifier of the customer. - * @property cardId The unique identifier of the card. - * @property lastFourDigits The last four digits of the card number. * @property currencyCode The code of the currency. * @property depositAddress The address for deposits, if available. - * @property isPinSet Indicates if the PIN is set for the card. * @property fiatBalance The fiat balance details. * @property cryptoBalance The crypto balance details. + * @property cards The list of user's cards. */ @Serializable data class Loaded( override val source: StatusSource, val customerId: String, - val cardId: String, - val lastFourDigits: String, val currencyCode: String, val depositAddress: String?, - val isPinSet: Boolean, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, val cryptoCurrency: CryptoCurrency.Token, - val displayName: CardDisplayName?, + val cards: List, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt new file mode 100644 index 0000000000..115e387985 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.models.pay + +import com.tangem.domain.models.account.CardDisplayName +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents a Tangem Pay card linked to a payment account. + * + * @property id unique card identifier assigned by the backend. + * @property hasPinCode whether the card has a PIN code set. + * @property displayName optional human-readable name assigned to the card; `null` if not set. + * @property limit spending limit configuration for the card; `null` if not configured or not yet loaded. + * @property isFrozen whether the card is currently frozen (blocked for payments). + * @property lastDigits The last four digits of the card number. + */ +@Serializable +data class TangemPayCard( + @SerialName("id") val id: String, + @SerialName("has_pin_code") val hasPinCode: Boolean, + @SerialName("display_name") val displayName: CardDisplayName?, + @SerialName("limit") val limit: TangemPayCardLimitData?, + @SerialName("is_frozen") val isFrozen: Boolean, + @SerialName("last_digits") val lastDigits: String, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimit.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimit.kt new file mode 100644 index 0000000000..01df4b7f89 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimit.kt @@ -0,0 +1,49 @@ +package com.tangem.domain.models.pay + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.Locale + +@Serializable +data class TangemPayCardLimit( + @SerialName("amount") val amount: SerializedBigDecimal, + @SerialName("period") val period: TangemPayCardLimitPeriod, +) + +@Serializable +enum class TangemPayCardLimitPeriod { + @SerialName("DAY") + DAY, + + @SerialName("WEEK") + WEEK, + + @SerialName("MONTH") + MONTH, + + @SerialName("YEAR") + YEAR, + + @SerialName("ALL_TIME") + ALL_TIME, + + @SerialName("AUTHORIZATION") + AUTHORIZATION, + + @SerialName("UNKNOWN") + UNKNOWN, + ; + + companion object { + fun fromString(value: String) = when (value.uppercase(Locale.US)) { + "DAY" -> DAY + "WEEK" -> WEEK + "MONTH" -> MONTH + "YEAR" -> YEAR + "ALL_TIME" -> ALL_TIME + "AUTHORIZATION" -> AUTHORIZATION + else -> UNKNOWN + } + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimitData.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimitData.kt new file mode 100644 index 0000000000..bffc3a671a --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCardLimitData.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.models.pay + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TangemPayCardLimitData( + @SerialName("actual_card_limit") val actualCardLimit: TangemPayCardLimit?, + @SerialName("admin_card_limit") val adminCardLimit: TangemPayCardLimit?, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayEligibilityType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt similarity index 89% rename from domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayEligibilityType.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt index 7f449b1bcd..2431867555 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayEligibilityType.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.models +package com.tangem.domain.models.pay enum class TangemPayEligibilityType { diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayReissueCardFee.kt similarity index 77% rename from domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayReissueCardFee.kt index 515857f8a7..26cde00590 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/TangemPayReissueCardFee.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayReissueCardFee.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.models +package com.tangem.domain.models.pay import java.math.BigDecimal diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 4f04e19fa0..c0bc32185f 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -1,5 +1,6 @@ package com.tangem.domain.pay.model +import com.tangem.domain.models.pay.TangemPayCardLimit import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCardLimit.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCardLimit.kt deleted file mode 100644 index 63ba23859b..0000000000 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayCardLimit.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tangem.domain.pay.model - -import java.math.BigDecimal -import java.util.Locale - -data class TangemPayCardLimit( - val amount: BigDecimal, - val period: TangemPayCardLimitPeriod, -) - -enum class TangemPayCardLimitPeriod { - DAY, - WEEK, - MONTH, - YEAR, - ALL_TIME, - AUTHORIZATION, - UNKNOWN, - ; - - companion object { - fun fromString(value: String) = when (value.uppercase(Locale.US)) { - "DAY" -> DAY - "WEEK" -> WEEK - "MONTH" -> MONTH - "YEAR" -> YEAR - "ALL_TIME" -> ALL_TIME - "AUTHORIZATION" -> AUTHORIZATION - else -> UNKNOWN - } - } -} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt index 8fcab526ee..bce59b45c7 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -2,7 +2,7 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError -import com.tangem.domain.models.TangemPayEligibilityType +import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.visa.error.VisaApiError diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt index 2342faff2c..3f756df4ee 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt @@ -3,7 +3,7 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.models.TangemPayReissueCardFee +import com.tangem.domain.models.pay.TangemPayReissueCardFee import com.tangem.domain.pay.model.TangemPayReissueOrderInfo import com.tangem.domain.visa.error.VisaApiError diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt index fe66dc36f7..4200d87d77 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt @@ -22,7 +22,6 @@ class GetPaymentAccountCryptoCurrencyStatusUseCase( val accountStatus = paymentAccountStatusSupplier.invoke(userWalletId).firstOrNull() ?: return none() val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) { is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus - is PaymentAccountStatusValue.Locked -> statusValue.cryptoCurrencyStatus else -> return none() } return if (cryptoCurrencyStatus.currency == cryptoCurrency) { diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt index 2daa88480b..e2cc71268c 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/destination/model/SendDestinationModel.kt @@ -262,7 +262,6 @@ internal class SendDestinationModel @Inject constructor( val contractAddress = (cryptoCurrency as? CryptoCurrency.Token)?.contractAddress ?: return null val (paymentAccountAddress, currency) = when (val status = this.value) { is PaymentAccountStatusValue.Loaded -> status.cryptoBalance.depositAddress to status.cryptoCurrency - is PaymentAccountStatusValue.Locked -> status.cryptoBalance.depositAddress to status.cryptoCurrency else -> return null } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 5b2b9b002e..2a5a8f55ea 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -187,7 +187,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { val currencyStatus = when (val statusValue = accountStatus.value) { is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus - is PaymentAccountStatusValue.Locked -> statusValue.cryptoCurrencyStatus else -> return emptyList() } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index 296375932e..3ad65aa9dd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -188,7 +188,6 @@ internal class ChooseTokenListItemConverter( PaymentAccountStatusValue.Loading, -> return null is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus - is PaymentAccountStatusValue.Locked -> status.cryptoCurrencyStatus } val account = this.account val tokensCount = 1 diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 4371b252e4..dd917953d6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -18,12 +18,15 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardLimitPeriod +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.pay.model.TangemPayCardLimitPeriod import com.tangem.domain.pay.model.TangemPayReissueOrderInfo import com.tangem.domain.pay.model.TangemPayTopUpData -import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents @@ -33,22 +36,15 @@ import com.tangem.features.tangempay.components.ReissueCardListener import com.tangem.features.tangempay.components.TangemPayCardPageComponent import com.tangem.features.tangempay.components.ViewPinListener import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.AddToWalletBlockState -import com.tangem.features.tangempay.entity.TangemPayCardPageSetting -import com.tangem.features.tangempay.entity.TangemPayCardPageUM -import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation +import com.tangem.features.tangempay.entity.* import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute -import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState import com.tangem.features.tangempay.utils.TangemPayMessagesFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -57,19 +53,17 @@ import javax.inject.Inject @ModelScoped internal class TangemPayCardPageModel @Inject constructor( paramsContainer: ParamsContainer, + paymentAccountStatusSupplier: PaymentAccountStatusSupplier, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, private val analytics: AnalyticsEventHandler, private val cardDetailsRepository: TangemPayCardDetailsRepository, - private val onboardingRepository: OnboardingRepository, private val uiMessageSender: UiMessageSender, private val reissueCardRepository: TangemPayReissueCardRepository, ) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener { private val params: TangemPayCardPageComponent.Params = paramsContainer.require() - private var currentFrozenState: TangemPayCardFrozenState = params.config.cardFrozenState - private val addToWalletBannerJobHolder = JobHolder() private val addFundsJobHolder = JobHolder() @@ -78,34 +72,63 @@ internal class TangemPayCardPageModel @Inject constructor( TangemPayCardPageUM( onBackClick = router::pop, dailyLimitState = TangemPayDailyLimitBlockState.Loading, - settings = persistentListOf( - TangemPayCardPageSetting( - title = TextReference.Res(R.string.tangempay_card_details_change_pin), - onSettingClick = ::onClickChangePIN, - ), - TangemPayCardPageSetting( - title = TextReference.Res(R.string.tangempay_card_details_freeze_card), - onSettingClick = ::onClickFreezeOrUnfreezeCard, - ), - TangemPayCardPageSetting( - title = TextReference.Res(R.string.tangempay_card_details_reissue_card), - onSettingClick = ::onClickReissueCard, - ), - ), + settings = persistentListOf(), ), ) val bottomSheetNavigation: SlotNavigation = SlotNavigation() + // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed init { - // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed fetchAddToWalletBanner() - fetchCardLimit() - subscribeToCardFrozenState() + + paymentAccountStatusSupplier.invoke(params.userWalletId) + .onEach { state -> + val status = state.value + if (status is PaymentAccountStatusValue.Loaded && + status.source == StatusSource.ACTUAL && + status.cards.isNotEmpty() + ) { + val card = status.cards.first() + val limit = card.limit?.actualCardLimit?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } + val dailyLimitState = if (limit != null) { + TangemPayDailyLimitBlockState.Content( + limit = limit.amount.format { + val symbol = getJavaCurrencyByCode(status.currencyCode).symbol + fiat(status.currencyCode, symbol) + }, + onChangeClick = {}, // TODO v_rodionov: #[REDACTED_TASK_KEY] + ) + } else { + TangemPayDailyLimitBlockState.Error + } + uiState.update { it.copy(dailyLimitState = dailyLimitState, settings = buildSettings(card)) } + } else { + // TODO v_rodionov: #[REDACTED_TASK_KEY] show error state + } + } + .launchIn(modelScope) } - private fun onClickChangePIN() { - if (!params.config.isPinSet) { + private fun buildSettings(card: TangemPayCard): ImmutableList { + return persistentListOf( + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_change_pin), + onSettingClick = { onClickChangePIN(card.hasPinCode) }, + ), + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_freeze_card), + onSettingClick = { onClickFreezeOrUnfreezeCard(card.isFrozen) }, + ), + TangemPayCardPageSetting( + title = TextReference.Res(R.string.tangempay_card_details_reissue_card), + onSettingClick = ::onClickReissueCard, + ), + ) + } + + private fun onClickChangePIN(isPinSet: Boolean) { + if (!isPinSet) { router.push(TangemPayDetailsInnerRoute.ChangePIN) } else { bottomSheetNavigation.activate( @@ -117,15 +140,13 @@ internal class TangemPayCardPageModel @Inject constructor( } } - private fun onClickFreezeOrUnfreezeCard() { - when (currentFrozenState) { - TangemPayCardFrozenState.Frozen -> uiMessageSender.send( - TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard), - ) - else -> uiMessageSender.send( - TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard), - ) + private fun onClickFreezeOrUnfreezeCard(isFrozen: Boolean) { + val message = if (isFrozen) { + TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard) + } else { + TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard) } + uiMessageSender.send(message) } private fun onClickReissueCard() { @@ -242,13 +263,6 @@ internal class TangemPayCardPageModel @Inject constructor( } } - private fun subscribeToCardFrozenState() { - cardDetailsRepository - .cardFrozenState(params.config.cardId) - .onEach { state -> currentFrozenState = state } - .launchIn(modelScope) - } - private fun fetchAddToWalletBanner() { modelScope.launch { val isDone = cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true @@ -265,39 +279,6 @@ internal class TangemPayCardPageModel @Inject constructor( }.saveIn(addToWalletBannerJobHolder) } - private fun fetchCardLimit() { - modelScope.launch { - onboardingRepository.getCustomerInfo(params.userWalletId) - .onRight { info -> - val productInstance = info.productInstance - val cardInfo = info.cardInfo - val actualCardLimit = productInstance?.actualCardLimit - val dailyLimitState = if ( - productInstance != null && - cardInfo != null && - actualCardLimit?.period == TangemPayCardLimitPeriod.DAY - ) { - val limit = actualCardLimit.amount.format { - val symbol = getJavaCurrencyByCode(cardInfo.currencyCode).symbol - fiat(cardInfo.currencyCode, symbol) - } - TangemPayDailyLimitBlockState.Content( - limit = limit, - onChangeClick = {}, // TODO v_rodionov: #[REDACTED_TASK_KEY] - ) - } else { - TangemPayDailyLimitBlockState.Error - } - uiState.update { it.copy(dailyLimitState = dailyLimitState) } - } - .onLeft { - uiState.update { state -> - state.copy(dailyLimitState = TangemPayDailyLimitBlockState.Error) - } - } - } - } - private fun onClickAddToWallet() { router.push(TangemPayDetailsInnerRoute.AddToWallet) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 4dc9493025..36bc62a6cc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -213,7 +213,6 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( is PaymentAccountStatusValue.IssuingCard, is PaymentAccountStatusValue.Loaded, is PaymentAccountStatusValue.Loading, - is PaymentAccountStatusValue.Locked, is PaymentAccountStatusValue.UnderReview, -> null } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index 8ce6b1a9c0..203004c0fc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -237,7 +237,6 @@ internal class GetWalletNotificationsFactory @Inject constructor( is PaymentAccountStatusValue.IssuingCard, is PaymentAccountStatusValue.Loaded, is PaymentAccountStatusValue.Loading, - is PaymentAccountStatusValue.Locked, is PaymentAccountStatusValue.UnderReview, -> null } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index a065f12000..77a4f8c17b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -55,54 +55,37 @@ internal class TangemPayMainBlockConverter( ) is PaymentAccountStatusValue.NotCreated -> TangemPayMainUM.Empty is PaymentAccountStatusValue.Loading -> TangemPayMainUM.Loading - is PaymentAccountStatusValue.Locked -> TangemPayMainUM.Content( - subtitle = stringReference("*${statusValue.lastFourDigits}"), - isBalanceFlickering = statusValue.source == StatusSource.CACHE, - balance = getBalanceText( - currencyCode = statusValue.currencyCode, - balance = statusValue.fiatBalance.availableBalance, - ), - balanceSubtitle = stringReference("USDC"), // TODO hardcode for now - shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, - onClick = { - tangemPayClickIntents.openDetails( - value.account.userWalletId, - TangemPayDetailsConfig( - customerId = statusValue.customerId, - cardId = statusValue.cardId, - isPinSet = statusValue.isPinSet, - cardFrozenState = TangemPayCardFrozenState.Frozen, - cardNumberEnd = statusValue.lastFourDigits, - chainId = POLYGON_CHAIN_ID, - displayName = statusValue.displayName, - ), - ) - }, - ) - is PaymentAccountStatusValue.Loaded -> TangemPayMainUM.Content( - subtitle = stringReference("*${statusValue.lastFourDigits}"), - isBalanceFlickering = statusValue.source == StatusSource.CACHE, - balance = getBalanceText( - currencyCode = statusValue.currencyCode, - balance = statusValue.fiatBalance.availableBalance, - ), - balanceSubtitle = stringReference("USDC"), // TODO hardcode for now - shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, - onClick = { - tangemPayClickIntents.openDetails( - value.account.userWalletId, - TangemPayDetailsConfig( - customerId = statusValue.customerId, - cardId = statusValue.cardId, - isPinSet = statusValue.isPinSet, - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - cardNumberEnd = statusValue.lastFourDigits, - chainId = POLYGON_CHAIN_ID, - displayName = statusValue.displayName, - ), - ) - }, - ) + is PaymentAccountStatusValue.Loaded -> { + val card = statusValue.cards.firstOrNull() ?: return TangemPayMainUM.TemporaryUnavailable + TangemPayMainUM.Content( + subtitle = stringReference("*${card.lastDigits}"), + isBalanceFlickering = statusValue.source == StatusSource.CACHE, + balance = getBalanceText( + currencyCode = statusValue.currencyCode, + balance = statusValue.fiatBalance.availableBalance, + ), + balanceSubtitle = stringReference("USDC"), // TODO hardcode for now + shouldShowOnlyCacheWarning = statusValue.source == StatusSource.ONLY_CACHE, + onClick = { + tangemPayClickIntents.openDetails( + value.account.userWalletId, + TangemPayDetailsConfig( + customerId = statusValue.customerId, + cardId = card.id, + isPinSet = card.hasPinCode, + cardFrozenState = if (card.isFrozen) { + TangemPayCardFrozenState.Frozen + } else { + TangemPayCardFrozenState.Unfrozen + }, + cardNumberEnd = card.lastDigits, + chainId = POLYGON_CHAIN_ID, + displayName = card.displayName, + ), + ) + }, + ) + } } } From 0e169a5418b619d72aea40906d2ddb1b3b97fb6b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Apr 2026 10:45:35 +0100 Subject: [PATCH 099/206] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 2 +- .../com/tangem/common/routing/AppRoute.kt | 2 +- core/ui/src/main/res/drawable/ic_sync_56.xml | 21 ++++ .../features/biometry/impl/ui/AskBiometry.kt | 20 ++-- .../CreateWalletStartModel.kt | 2 +- .../CreateWalletStartModelTest.kt | 4 +- .../v2/entry/OnboardingEntryComponent.kt | 2 +- features/onboarding-v2/impl/build.gradle.kts | 1 + .../DefaultAddressSyncComponent.kt | 20 +++- .../addresssync/model/AddressSyncContract.kt | 14 +++ .../v2/addresssync/model/AddressSyncIntent.kt | 8 -- .../v2/addresssync/model/AddressSyncModel.kt | 52 +++++++-- .../addresssync/navigation/AddressSyncStep.kt | 2 +- .../addresssync/ui/AddressSyncButtonScreen.kt | 105 ++++++++++++++++++ .../entry/impl/model/OnboardingEntryModel.kt | 2 +- .../api/OnboardingMultiWalletComponent.kt | 2 +- .../model/MultiWalletFinalizeModel.kt | 2 +- .../impl/model/OnboardingMultiWalletModel.kt | 4 +- .../addresssync/model/AddressSyncModelTest.kt | 64 +++++++++++ 19 files changed, 292 insertions(+), 37 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_sync_56.xml create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt delete mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 743496098a..0725d8d517 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -261,7 +261,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.Onboarding.Mode.UpgradeHotWallet -> OnboardingEntryComponent.Mode.UpgradeHotWallet(mode.userWalletId) is AppRoute.Onboarding.Mode.AddressSync -> - OnboardingEntryComponent.Mode.AddressSync + OnboardingEntryComponent.Mode.AddressSync(mode.userWalletId) }, ), componentFactory = onboardingEntryComponentFactory, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 165823622e..b70510260e 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -330,7 +330,7 @@ sealed class AppRoute(val path: String) : Route { data object RecreateWalletTwin : Mode() // reset twins data object ContinueFinalize : Mode() // continue finalize process (unfinished backup dialog) data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() // upgrade hot wallet - data object AddressSync : Mode() + data class AddressSync(val userWalletId: UserWalletId) : Mode() } } diff --git a/core/ui/src/main/res/drawable/ic_sync_56.xml b/core/ui/src/main/res/drawable/ic_sync_56.xml new file mode 100644 index 0000000000..112dd64452 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_sync_56.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/ui/AskBiometry.kt b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/ui/AskBiometry.kt index a3726bf381..447e3a1238 100644 --- a/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/ui/AskBiometry.kt +++ b/features/biometry/impl/src/main/kotlin/com/tangem/features/biometry/impl/ui/AskBiometry.kt @@ -126,6 +126,16 @@ private fun Footer(state: AskBiometryUM, modifier: Modifier = Modifier) { .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { + Text( + modifier = Modifier.fillMaxWidth(fraction = .7f), + text = stringResourceSafe(R.string.save_user_wallet_agreement_notice), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + textAlign = TextAlign.Center, + ) + + SpacerH16() + PrimaryButton( modifier = Modifier.fillMaxWidth(), showProgress = state.shouldShowProgress, @@ -142,16 +152,6 @@ private fun Footer(state: AskBiometryUM, modifier: Modifier = Modifier) { onClick = state.onDontAllowClick, ) } - - SpacerH16() - - Text( - modifier = Modifier.fillMaxWidth(fraction = .7f), - text = stringResourceSafe(R.string.save_user_wallet_agreement_notice), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - ) } } diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 8c15de536f..91f69a736d 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -239,7 +239,7 @@ internal class CreateWalletStartModel @Inject constructor( val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { AppRoute.Onboarding( scanResponse = scanResponse, - mode = AppRoute.Onboarding.Mode.AddressSync, + mode = AppRoute.Onboarding.Mode.AddressSync(userWalletId = userWallet.walletId), ) } else { AppRoute.Wallet diff --git a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt index bd7640bef5..54b19b9b4f 100644 --- a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt +++ b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt @@ -536,7 +536,9 @@ internal class CreateWalletStartModelTest { routes = arrayOf( AppRoute.Onboarding( scanResponse = testScanResponse, - mode = AppRoute.Onboarding.Mode.AddressSync, + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = testUserWalletId, + ), ) ), onComplete = any() diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt index d4776bf833..a8314aff42 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt @@ -19,7 +19,7 @@ interface OnboardingEntryComponent : ComposableContentComponent { data object RecreateWalletTwin : Mode() data object ContinueFinalize : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() - data object AddressSync : Mode() + data class AddressSync(val userWalletId: UserWalletId) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/build.gradle.kts b/features/onboarding-v2/impl/build.gradle.kts index f4022573af..a2fadc93e1 100644 --- a/features/onboarding-v2/impl/build.gradle.kts +++ b/features/onboarding-v2/impl/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(projects.common) /** Domain */ + implementation(projects.domain.account) implementation(projects.domain.models) implementation(projects.domain.feedback) implementation(projects.domain.feedback.models) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index f5999ae461..4717f961c2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -1,7 +1,10 @@ package com.tangem.features.onboarding.v2.addresssync +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.DelicateDecomposeApi import com.arkivanov.decompose.extensions.compose.stack.Children import com.arkivanov.decompose.router.stack.ChildStack @@ -15,7 +18,9 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncIntent import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncState import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncButtonScreen import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncContent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.PushNotificationsComponent @@ -65,7 +70,20 @@ internal class DefaultAddressSyncComponent( return when (step) { AddressSyncStep.ASK_BIOMETRY -> createAskBiometryComponent(childContext) AddressSyncStep.ASK_NOTIFICATIONS -> createPushNotificationComponent(childContext) - AddressSyncStep.ADDRESS_SYNC -> TODO("Will be implemented during [REDACTED_TASK_KEY]") + AddressSyncStep.ADDRESS_SYNC -> ComposableContentComponent { + val state by model.state.collectAsStateWithLifecycle() + when (state) { + AddressSyncState.Loading -> Unit // todo shimmers will be implemented during [REDACTED_TASK_KEY] + is AddressSyncState.Success -> AddressSyncButtonScreen( + state = state as AddressSyncState.Success, + modifier = Modifier.fillMaxSize(), + onSyncClick = { + model.onIntent(AddressSyncIntent.Sync) + }, + ) + AddressSyncState.NoTokens -> router.replaceAll(AppRoute.Wallet) + } + } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt new file mode 100644 index 0000000000..f283b138e2 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt @@ -0,0 +1,14 @@ +package com.tangem.features.onboarding.v2.addresssync.model + +import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep + +internal sealed interface AddressSyncIntent { + data class Next(val step: AddressSyncStep) : AddressSyncIntent + data object Sync : AddressSyncIntent +} + +internal sealed class AddressSyncState { + data object Loading : AddressSyncState() + data class Success(val currenciesCount: Int) : AddressSyncState() + data object NoTokens : AddressSyncState() +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt deleted file mode 100644 index 0776ea3cc7..0000000000 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncIntent.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tangem.features.onboarding.v2.addresssync.model - -import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep - -sealed interface AddressSyncIntent { - data class Next(val step: AddressSyncStep) : AddressSyncIntent - data object Back : AddressSyncIntent -} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt index a73fa83fae..eb902893a3 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -2,35 +2,45 @@ package com.tangem.features.onboarding.v2.addresssync.model import com.arkivanov.decompose.DelicateDecomposeApi import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.replaceCurrent import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @OptIn(DelicateDecomposeApi::class) +@Suppress("LongParameterList") @ModelScoped internal class AddressSyncModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase, private val canUseBiometryUseCase: CanUseBiometryUseCase, private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, + private val multiWalletAccountListFetcher: MultiWalletAccountListFetcher, + private val multiAccountListSupplier: MultiAccountListSupplier, paramsContainer: ParamsContainer, ) : Model() { private val params = paramsContainer.require() + private val walletId = (params.parentParams.mode as OnboardingMultiWalletComponent.Mode.AddressSync).userWalletId val stackNavigation = StackNavigation() + val state: StateFlow + field = MutableStateFlow( + value = AddressSyncState.Loading, + ) init { params.innerNavigation.update { innerNavigationState -> @@ -42,12 +52,13 @@ internal class AddressSyncModel @Inject constructor( modelScope.launch { trySkippingScreen(AddressSyncStep.ASK_BIOMETRY) } + fetchWalletCrypto() } fun onIntent(intent: AddressSyncIntent) { when (intent) { is AddressSyncIntent.Next -> nextScreen(intent) - AddressSyncIntent.Back -> goBack() + AddressSyncIntent.Sync -> startSyncing() } } @@ -76,10 +87,6 @@ internal class AddressSyncModel @Inject constructor( } } - private fun goBack() { - stackNavigation.pop() - } - private fun updateStepperPage(next: AddressSyncIntent.Next) { params.innerNavigation.update { innerNavigationState -> innerNavigationState.copy( @@ -94,6 +101,37 @@ internal class AddressSyncModel @Inject constructor( ) } + private fun fetchWalletCrypto() { + modelScope.launch { + multiWalletAccountListFetcher.invoke( + params = MultiWalletAccountListFetcher.Params(userWalletId = walletId), + ) + handleAddressSyncStep() + } + } + + private suspend fun handleAddressSyncStep() { + multiAccountListSupplier() + .map { accountLists -> + accountLists + .first { it.userWalletId == walletId } + .flattenCurrencies() + } + .onEach { currencies -> + val updatedState = if (currencies.isEmpty()) { + AddressSyncState.NoTokens + } else { + AddressSyncState.Success(currenciesCount = currencies.size) + } + state.value = updatedState + } + .collect() + } + + private fun startSyncing() { + TODO("Will be implemented during [REDACTED_TASK_KEY]") + } + private companion object { const val ADDRESS_SYNC_MAX_STEPS = 3 } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt index c03a4fc662..f9d48107ca 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt @@ -3,7 +3,7 @@ package com.tangem.features.onboarding.v2.addresssync.navigation import androidx.annotation.StringRes import com.tangem.features.onboarding.v2.impl.R -enum class AddressSyncStep(val pageNumber: Int, @StringRes val stringId: Int) { +internal enum class AddressSyncStep(val pageNumber: Int, @StringRes val stringId: Int) { ASK_BIOMETRY(pageNumber = 1, stringId = R.string.onboarding_navbar_title_biometrics), ASK_NOTIFICATIONS(pageNumber = 2, stringId = R.string.onboarding_title_notifications), ADDRESS_SYNC(pageNumber = 3, stringId = R.string.onboarding_navbar_title_last_step), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt new file mode 100644 index 0000000000..94e2d60121 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt @@ -0,0 +1,105 @@ +package com.tangem.features.onboarding.v2.addresssync.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.* +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.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.PrimaryButtonIconEnd +import com.tangem.core.ui.components.SpacerHHalf +import com.tangem.core.ui.components.SpacerHMax +import com.tangem.core.ui.extensions.pluralStringResourceSafe +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncState +import com.tangem.features.onboarding.v2.impl.R + +@Composable +internal fun AddressSyncButtonScreen( + state: AddressSyncState.Success, + onSyncClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(top = TangemTheme.dimens.spacing56), + ) { + SpacerHHalf() + Icon( + painter = painterResource(id = R.drawable.ic_sync_56), + contentDescription = null, + tint = TangemTheme.colors.icon.accent, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .size(TangemTheme.dimens.size56), + ) + Text( + text = stringResourceSafe(R.string.onboarding_address_sync_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + start = TangemTheme.dimens.spacing34, + end = TangemTheme.dimens.spacing34, + top = TangemTheme.dimens.spacing28, + ), + ) + AddressSyncDescription(state.currenciesCount) + SpacerHMax() + PrimaryButtonIconEnd( + text = stringResourceSafe(R.string.common_generate_addresses), + onClick = onSyncClick, + iconResId = R.drawable.ic_tangem_24, + modifier = Modifier + .fillMaxWidth() + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing154, + bottom = TangemTheme.dimens.spacing16, + ), + ) + } +} + +@Composable +private fun ColumnScope.AddressSyncDescription(currenciesCount: Int) { + Text( + text = pluralStringResourceSafe( + id = R.plurals.onboarding_address_sync_description, + count = currenciesCount, + currenciesCount, + ), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding( + start = TangemTheme.dimens.spacing34, + end = TangemTheme.dimens.spacing34, + top = TangemTheme.dimens.spacing28, + ), + ) +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AddressSyncButtonScreenPreview() { + TangemThemePreview { + AddressSyncButtonScreen( + state = AddressSyncState.Success(currenciesCount = 2), + onSyncClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index 5747977a6d..aab619c0e9 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -82,7 +82,7 @@ internal class OnboardingEntryModel @Inject constructor( is Mode.UpgradeHotWallet -> OnboardingMultiWalletComponent.Mode.UpgradeHotWallet( userWalletId = mode.userWalletId, ) - is Mode.AddressSync -> OnboardingMultiWalletComponent.Mode.AddressSync + is Mode.AddressSync -> OnboardingMultiWalletComponent.Mode.AddressSync(mode.userWalletId) else -> error("Incorrect onboarding type") } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt index 9e4e023699..ed4280ad63 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt @@ -24,7 +24,7 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi data object AddBackup : Mode() data object ContinueFinalize : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() - data object AddressSync : Mode() + data class AddressSync(val userWalletId: UserWalletId) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt index 9dc1d725b0..b6431a21af 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModel.kt @@ -265,7 +265,7 @@ internal class MultiWalletFinalizeModel @Inject constructor( val userWallet = when (params.parentParams.mode) { OnboardingMultiWalletComponent.Mode.Onboarding, OnboardingMultiWalletComponent.Mode.ContinueFinalize, - OnboardingMultiWalletComponent.Mode.AddressSync, + is OnboardingMultiWalletComponent.Mode.AddressSync, -> { saveWalletUseCase.invoke( userWallet = userWalletCreated.copy( diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt index 050a14f74e..0d3c27e10a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -71,7 +71,7 @@ internal class OnboardingMultiWalletModel @Inject constructor( onConfirm = { modelScope.launch { onboardingRepository.clearUnfinishedFinalizeOnboarding() - if (params.mode == OnboardingMultiWalletComponent.Mode.AddressSync) { + if (params.mode is OnboardingMultiWalletComponent.Mode.AddressSync) { router.replaceAll(AppRoute.Wallet) } else { router.pop() @@ -128,7 +128,7 @@ internal class OnboardingMultiWalletModel @Inject constructor( params.mode == OnboardingMultiWalletComponent.Mode.ContinueFinalize -> { OnboardingMultiWalletState.Step.Finalize } - params.mode == OnboardingMultiWalletComponent.Mode.AddressSync -> { + params.mode is OnboardingMultiWalletComponent.Mode.AddressSync -> { OnboardingMultiWalletState.Step.AddressSync } // Add backup button diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt index f784c38c74..8282ed6639 100644 --- a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -1,23 +1,32 @@ package com.tangem.features.onboarding.v2.addresssync.model +import arrow.core.Either import com.arkivanov.decompose.router.stack.StackNavigation import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.features.onboarding.v2.TitleProvider import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.MultiWalletInnerNavigationState import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -31,6 +40,8 @@ internal class AddressSyncModelTest { private val shouldShowAskBiometryUseCase: ShouldShowAskBiometryUseCase = mockk() private val canUseBiometryUseCase: CanUseBiometryUseCase = mockk() private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase = mockk() + private val multiWalletAccountListFetcher: MultiWalletAccountListFetcher = mockk() + private val multiAccountListSupplier: MultiAccountListSupplier = mockk() private val paramsContainer: ParamsContainer = mockk() private val testInnerNavigation = MutableStateFlow( value = MultiWalletInnerNavigationState( @@ -39,10 +50,12 @@ internal class AddressSyncModelTest { ) ) private val titleProvider: TitleProvider = mockk(relaxUnitFun = true) + private val walletId = UserWalletId("011") private val params: MultiWalletChildParams = mockk { every { innerNavigation } returns testInnerNavigation every { parentParams } returns mockk { every { titleProvider } returns this@AddressSyncModelTest.titleProvider + every { mode } returns OnboardingMultiWalletComponent.Mode.AddressSync(walletId) } } @@ -51,6 +64,10 @@ internal class AddressSyncModelTest { coEvery { canUseBiometryUseCase.strict() } returns false coEvery { shouldShowAskBiometryUseCase() } returns false coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + coEvery { multiWalletAccountListFetcher.invoke(any()) } returns Either.Right(Unit) + every { multiAccountListSupplier() } returns flowOf( + listOf(AccountList.empty(userWalletId = walletId)), + ) every { paramsContainer.require() } returns params } @@ -139,6 +156,51 @@ internal class AddressSyncModelTest { assertStepperAndTitleFor(AddressSyncStep.ADDRESS_SYNC) } + @Test + fun `GIVEN multiAccountListSupplier emits no currencies WHEN model is created THEN state is NoTokens`() = runTest { + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = emptyList(), + ), + ), + ) + + val model = createModel(this) + advanceUntilIdle() + + coVerify { + multiWalletAccountListFetcher.invoke( + params = MultiWalletAccountListFetcher.Params(userWalletId = walletId) + ) + } + assert(model.state.value == AddressSyncState.NoTokens) + } + + @Test + fun `GIVEN multiAccountListSupplier emits currencies WHEN model is created THEN state is Success`() = runTest { + val currencies = listOf(mockk(), mockk(), mockk()) + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = currencies, + ), + ), + ) + + val model = createModel(this) + advanceUntilIdle() + + coVerify { + multiWalletAccountListFetcher.invoke( + params = MultiWalletAccountListFetcher.Params(userWalletId = walletId) + ) + } + assert(model.state.value == AddressSyncState.Success(currenciesCount = currencies.size)) + } + private fun assertStepperAndTitleFor(step: AddressSyncStep) { assert(testInnerNavigation.value.stackSize == step.pageNumber) verify { titleProvider.changeTitle(resourceReference(step.stringId)) } @@ -160,6 +222,8 @@ internal class AddressSyncModelTest { shouldShowAskBiometryUseCase = shouldShowAskBiometryUseCase, canUseBiometryUseCase = canUseBiometryUseCase, shouldAskPermissionUseCase = shouldAskPermissionUseCase, + multiWalletAccountListFetcher = multiWalletAccountListFetcher, + multiAccountListSupplier = multiAccountListSupplier, paramsContainer = paramsContainer, ) } From 92f5623ba65396c918ef6bd47d98fdc5e10c7859 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Apr 2026 12:33:43 +0100 Subject: [PATCH 100/206] Updated on 2026-08-14 --- .../DefaultAddressSyncComponent.kt | 5 +- .../v2/addresssync/model/AddressSyncModel.kt | 10 +++- .../addresssync/model/AddressSyncModelTest.kt | 60 ++++++++++++++----- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index 4717f961c2..ab4f310b25 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -2,6 +2,7 @@ package com.tangem.features.onboarding.v2.addresssync import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -81,7 +82,9 @@ internal class DefaultAddressSyncComponent( model.onIntent(AddressSyncIntent.Sync) }, ) - AddressSyncState.NoTokens -> router.replaceAll(AppRoute.Wallet) + AddressSyncState.NoTokens -> LaunchedEffect(Unit) { + router.replaceAll(AppRoute.Wallet) + } } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt index eb902893a3..d140c874f7 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -103,10 +103,16 @@ internal class AddressSyncModel @Inject constructor( private fun fetchWalletCrypto() { modelScope.launch { - multiWalletAccountListFetcher.invoke( + multiWalletAccountListFetcher( params = MultiWalletAccountListFetcher.Params(userWalletId = walletId), + ).fold( + ifLeft = { + state.value = AddressSyncState.NoTokens + }, + ifRight = { + handleAddressSyncStep() + }, ) - handleAddressSyncStep() } } diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt index 8282ed6639..6cd4a728ba 100644 --- a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -19,11 +19,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.MultiWalletInnerNaviga import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -31,6 +27,7 @@ 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 @@ -76,8 +73,8 @@ internal class AddressSyncModelTest { createModel(this) val state = testInnerNavigation.value - assert(state.stackSize == AddressSyncStep.ASK_BIOMETRY.pageNumber) - assert(state.stackMaxSize == AddressSyncStep.entries.size) + Assertions.assertEquals(AddressSyncStep.ASK_BIOMETRY.pageNumber, state.stackSize) + Assertions.assertEquals(AddressSyncStep.entries.size, state.stackMaxSize) } @Test @@ -91,7 +88,7 @@ internal class AddressSyncModelTest { model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY)) advanceUntilIdle() - assert(stack == listOf(AddressSyncStep.ASK_BIOMETRY)) + Assertions.assertEquals(listOf(AddressSyncStep.ASK_BIOMETRY), stack) assertStepperAndTitleFor(AddressSyncStep.ASK_BIOMETRY) } @@ -108,7 +105,7 @@ internal class AddressSyncModelTest { model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY)) advanceUntilIdle() - assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS)) + Assertions.assertEquals(listOf(AddressSyncStep.ASK_NOTIFICATIONS), stack) assertStepperAndTitleFor(AddressSyncStep.ASK_NOTIFICATIONS) } @@ -124,7 +121,7 @@ internal class AddressSyncModelTest { model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY)) advanceUntilIdle() - assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC)) + Assertions.assertEquals(listOf(AddressSyncStep.ADDRESS_SYNC), stack) assertStepperAndTitleFor(AddressSyncStep.ADDRESS_SYNC) } @@ -138,7 +135,7 @@ internal class AddressSyncModelTest { model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS)) advanceUntilIdle() - assert(stack == listOf(AddressSyncStep.ASK_NOTIFICATIONS)) + Assertions.assertEquals(listOf(AddressSyncStep.ASK_NOTIFICATIONS), stack) assertStepperAndTitleFor(AddressSyncStep.ASK_NOTIFICATIONS) } @@ -152,7 +149,7 @@ internal class AddressSyncModelTest { model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS)) advanceUntilIdle() - assert(stack == listOf(AddressSyncStep.ADDRESS_SYNC)) + Assertions.assertEquals(listOf(AddressSyncStep.ADDRESS_SYNC), stack) assertStepperAndTitleFor(AddressSyncStep.ADDRESS_SYNC) } @@ -175,7 +172,7 @@ internal class AddressSyncModelTest { params = MultiWalletAccountListFetcher.Params(userWalletId = walletId) ) } - assert(model.state.value == AddressSyncState.NoTokens) + Assertions.assertEquals(AddressSyncState.NoTokens, model.state.value) } @Test @@ -198,11 +195,44 @@ internal class AddressSyncModelTest { params = MultiWalletAccountListFetcher.Params(userWalletId = walletId) ) } - assert(model.state.value == AddressSyncState.Success(currenciesCount = currencies.size)) + Assertions.assertEquals( + AddressSyncState.Success(currenciesCount = currencies.size), + model.state.value, + ) + } + + @Test + fun `WHEN multiWalletAccountListFetcher emits error WHEN model is created THEN get NoToken state`() = runTest { + val currencies = listOf(mockk(), mockk(), mockk()) + coEvery { multiWalletAccountListFetcher.invoke(any()) } returns Either.Left( + value = IllegalStateException("Test") + ) + + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = currencies, + ), + ), + ) + + val model = createModel(this) + advanceUntilIdle() + + coVerify { + multiWalletAccountListFetcher.invoke( + params = MultiWalletAccountListFetcher.Params(userWalletId = walletId) + ) + } + Assertions.assertEquals( + AddressSyncState.NoTokens, + model.state.value, + ) } private fun assertStepperAndTitleFor(step: AddressSyncStep) { - assert(testInnerNavigation.value.stackSize == step.pageNumber) + Assertions.assertEquals(step.pageNumber, testInnerNavigation.value.stackSize) verify { titleProvider.changeTitle(resourceReference(step.stringId)) } } From 2be74452121a1baf7d44e741cae548eee27973f0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Apr 2026 18:01:25 +0500 Subject: [PATCH 101/206] Updated on 2026-08-14 --- .../tangem/tap/di/domain/SwapDomainModule.kt | 12 + .../exchangeServices/DefaultRampManager.kt | 67 +- .../tangem/tap/routing/utils/ChildFactory.kt | 9 +- .../com/tangem/common/routing/AppRoute.kt | 18 +- .../ui/markets/action/TokenActionsHandler.kt | 3 +- .../exchangeservice/swap/ExpressUtils.kt | 2 +- data/swap/build.gradle.kts | 10 + .../data/swap/DefaultSwapRepositoryV2.kt | 99 +- .../swap/DefaultSwapTransactionRepository.kt | 42 +- .../SavedSwapTransactionListConverter.kt | 12 +- .../swap/models/SwapTransactionListDTO.kt | 4 +- .../data/swap/DefaultSwapRepositoryV2Test.kt | 709 +++++++ .../domain/express/models/ExpressError.kt | 9 + .../express/models/ExpressProviderType.kt | 18 +- .../domain/exchange/RampStateManager.kt | 11 + .../domain/swap/models/SwapCurrencyStatus.kt | 33 + .../swap/models/SwapTransactionListModel.kt | 3 +- .../tangem/domain/swap/SwapRepositoryV2.kt | 17 +- .../domain/swap/SwapTransactionRepository.kt | 6 +- .../domain/swap/usecase/GetSwapDataUseCase.kt | 3 +- .../domain/swap/usecase/GetSwapPairUseCase.kt | 35 + .../usecase/SwapTransactionSentUseCase.kt | 11 +- .../tokens/actions/CommonActionsFactory.kt | 52 +- ...ymentAccountCryptoCurrencyStatusUseCase.kt | 21 +- .../deeplink/DefaultSwapDeepLinkHandler.kt | 10 +- .../swap/model/SwapSelectTokensModel.kt | 3 +- .../tokenlist/model/OnrampTokenListModel.kt | 5 +- .../amount/model/SendAmountModel.kt | 13 +- .../confirm/model/SwapTransactionSender.kt | 18 +- features/swap/CLAUDE.md | 148 ++ .../com/tangem/features/swap/SwapComponent.kt | 15 +- .../feature/swap/DefaultSwapRepository.kt | 6 +- .../swap/DefaultSwapTransactionRepository.kt | 31 +- .../SavedSwapTransactionListConverter.kt | 12 +- features/swap/domain/build.gradle.kts | 2 + .../DefaultInitialToCurrencyResolver.kt | 43 - .../swap/domain/InitialToCurrencyResolver.kt | 18 - .../feature/swap/domain/SwapInteractor.kt | 148 +- .../feature/swap/domain/SwapInteractorImpl.kt | 1309 +++++-------- .../swap/domain/SwapTransactionRepository.kt | 3 +- .../feature/swap/domain/api/SwapRepository.kt | 7 +- .../swap/domain/di/SwapDomainModule.kt | 25 +- .../swap/domain/models/domain/NetworkInfo.kt | 6 - .../domain/models/domain/PermissionOptions.kt | 23 - .../models/domain/PreparedSwapConfigState.kt | 2 - .../domain/SavedSwapTransactionListModel.kt | 7 +- .../domain/models/domain/SwapApproveType.kt | 12 - .../swap/domain/models/domain/SwapFeeState.kt | 3 - .../domain/models/domain/SwapPairLeast.kt | 22 +- .../swap/domain/models/ui/SwapState.kt | 16 +- .../domain/models/ui/SwapTransactionState.kt | 9 +- features/swap/impl/build.gradle.kts | 10 + .../feature/swap/DefaultSwapComponent.kt | 157 +- .../feature/swap/analytics/SwapEvents.kt | 23 - .../swap/choosetoken/api/ChooseTokenBridge.kt | 2 +- .../impl/DefaultChooseTokenComponent.kt | 10 +- .../swap/model/InitialCurrenciesResolver.kt | 225 +++ .../tangem/feature/swap/model/SwapModel.kt | 1622 ++++++----------- .../swap/model/SwapNotificationsFactory.kt | 59 +- .../swap/model/SwapProcessDataState.kt | 22 +- .../feature/swap/models/SwapStateHolder.kt | 47 +- .../swap/models/TokenSelectionDirection.kt | 6 + .../tangem/feature/swap/models/UiActions.kt | 11 +- .../swap/models/states/SwapNotificationUM.kt | 4 +- .../tangem/feature/swap/router/SwapRoute.kt | 9 + .../tangem/feature/swap/router/SwapRouter.kt | 71 - .../tangem/feature/swap/ui/StateBuilder.kt | 773 ++++---- .../com/tangem/feature/swap/ui/SwapScreen.kt | 5 +- .../feature/swap/ui/SwapScreenContent.kt | 92 +- .../tangem/feature/swap/ui/TransactionCard.kt | 502 +++-- .../ui/preview/SwapTransactionCardPreview.kt | 79 + .../DefaultInitialCurrenciesResolverTest.kt | 867 +++++++++ .../tangempay/model/TangemPayCardPageModel.kt | 4 +- .../tangempay/model/TangemPayDetailsModel.kt | 10 +- .../tokendetails/model/TokenDetailsModel.kt | 4 +- .../tokendetails/state/express/ExchangeUM.kt | 2 + ...enDetailsSwapTransactionsStateConverter.kt | 2 + .../factory/express/ExchangeStatusFactory.kt | 23 +- .../TokenDetailsExchangeStatusFactory.kt | 85 +- .../ExpressStatusBottomSheetStateProvider.kt | 2 + .../WalletCurrencyActionsClickIntents.kt | 28 +- 81 files changed, 4444 insertions(+), 3434 deletions(-) create mode 100644 data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt create mode 100644 domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt create mode 100644 domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt create mode 100644 features/swap/CLAUDE.md delete mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt delete mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt delete mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt delete mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt delete mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt delete mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt index 8cf51ccfe1..4a30ff772b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt @@ -49,6 +49,18 @@ internal object SwapDomainModule { ) } + @Provides + @Singleton + fun provideGetSwapPairUseCase( + swapRepositoryV2: SwapRepositoryV2, + swapErrorResolver: SwapErrorResolver, + ): GetSwapPairUseCase { + return GetSwapPairUseCase( + swapRepositoryV2 = swapRepositoryV2, + swapErrorResolver = swapErrorResolver, + ) + } + @Provides @Singleton fun provideSelectInitialPairUseCase( diff --git a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt index aed385bef1..e9bc94300b 100644 --- a/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt +++ b/app/src/main/java/com/tangem/tap/network/exchangeServices/DefaultRampManager.kt @@ -85,11 +85,29 @@ internal class DefaultRampManager( cryptoCurrency: CryptoCurrency, ): ScenarioUnavailabilityReason { val availabilityState = runSuspendCatching { - getExchangeableState() + getExchangeableState( + userWalletId = userWalletId, + cryptoCurrency = cryptoCurrency, + ) }.getOrNull() ?: ExpressAvailabilityState.Error return availabilityState.toReason(cryptoCurrency.name) } + override suspend fun availableForSwap( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Map { + val availabilityStates = runSuspendCatching { + getExchangeableStates( + userWalletId = userWalletId, + cryptoCurrencies = cryptoCurrencies, + ) + }.getOrNull() ?: cryptoCurrencies.associateWith { ExpressAvailabilityState.Error } + return availabilityStates.mapValues { entry -> + entry.value.toReason(entry.key.name) + } + } + override fun getSellInitializationStatus(): Flow { return sellService.initializationStatus } @@ -141,9 +159,42 @@ internal class DefaultRampManager( } } - private fun getExchangeableState(): ExpressAvailabilityState { - // In task [REDACTED_TASK_KEY], removed all checks to make all tokens available - return ExpressAvailabilityState.Available + private suspend fun getExchangeableState( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): ExpressAvailabilityState { + val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull() + ?: return ExpressAvailabilityState.Loading + + return when (asset) { + is Lce.Error -> ExpressAvailabilityState.Error + is Lce.Loading -> ExpressAvailabilityState.Loading + is Lce.Content -> { + val foundAsset = asset.getOrNull()?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) } + foundAsset?.isExchangeAvailable?.toSwapAvailabilityState() + ?: ExpressAvailabilityState.AssetNotFound + } + } + } + + private suspend fun getExchangeableStates( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Map { + val asset = expressServiceFetcher.getInitializationStatus(userWalletId).firstOrNull() + ?: return cryptoCurrencies.associateWith { ExpressAvailabilityState.Loading } + + return when (asset) { + is Lce.Error -> cryptoCurrencies.associateWith { ExpressAvailabilityState.Error } + is Lce.Loading -> cryptoCurrencies.associateWith { ExpressAvailabilityState.Loading } + is Lce.Content -> { + val foundAsset = asset.getOrNull() + cryptoCurrencies.associateWith { cryptoCurrency -> + foundAsset?.find { cryptoCurrency.findAssetPredicate(assetId = it.id) }?.isExchangeAvailable + ?.toSwapAvailabilityState() ?: ExpressAvailabilityState.AssetNotFound + } + } + } } private suspend fun getOnrampAvailableState( @@ -177,6 +228,14 @@ internal class DefaultRampManager( } } + private fun Boolean.toSwapAvailabilityState(): ExpressAvailabilityState { + return if (this) { + ExpressAvailabilityState.Available + } else { + ExpressAvailabilityState.NotExchangeable + } + } + private fun Boolean.toOnrampAvailabilityState(): ExpressAvailabilityState { return if (this) { ExpressAvailabilityState.Available diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 743496098a..c5eac22cc5 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -304,11 +304,14 @@ internal class ChildFactory @Inject constructor( createComponentChild( context = context, params = SwapComponent.Params( - currencyFrom = route.currencyFrom, - currencyTo = route.currencyTo, + cryptoCurrency = route.cryptoCurrency, userWalletId = route.userWalletId, - isInitialReverseOrder = route.isInitialReverseOrder, screenSource = route.screenSource, + currencyPosition = when (route.currencyPosition) { + AppRoute.Swap.CurrencyPosition.FROM -> SwapComponent.Params.CurrencyPosition.FROM + AppRoute.Swap.CurrencyPosition.TO -> SwapComponent.Params.CurrencyPosition.TO + AppRoute.Swap.CurrencyPosition.ANY -> SwapComponent.Params.CurrencyPosition.ANY + }, tangemPayInput = route.tangemPayInput?.let { tangemPayInput -> SwapComponent.Params.TangemPayInput( cryptoAmount = tangemPayInput.cryptoAmount, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 165823622e..ce028bdc24 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -196,18 +196,15 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Swap( - val currencyFrom: CryptoCurrency, - val currencyTo: CryptoCurrency? = null, val userWalletId: UserWalletId, - val isInitialReverseOrder: Boolean = false, + val cryptoCurrency: CryptoCurrency? = null, val screenSource: String, + val currencyPosition: CurrencyPosition = CurrencyPosition.ANY, val tangemPayInput: TangemPayInput? = null, ) : AppRoute( path = "/swap" + - "/${currencyFrom.id.value}" + - "/${currencyTo?.id?.value}" + - "/${userWalletId.stringValue}" + - "/$isInitialReverseOrder", + "/${cryptoCurrency?.id?.value}" + + "/${userWalletId.stringValue}", ) { @Serializable data class TangemPayInput( @@ -216,6 +213,13 @@ sealed class AppRoute(val path: String) : Route { val depositAddress: String, val isWithdrawal: Boolean, ) + + @Serializable + enum class CurrencyPosition { + FROM, + TO, + ANY, + } } @Serializable diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt index 90f8b3281b..e24e2ee1ed 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt @@ -118,9 +118,8 @@ class TokenActionsHandler @AssistedInject constructor( private fun onExchangeClick(cryptoCurrencyData: CryptoCurrencyData) { router.push( AppRoute.Swap( - currencyFrom = cryptoCurrencyData.status.currency, + cryptoCurrency = cryptoCurrencyData.status.currency, userWalletId = cryptoCurrencyData.userWallet.walletId, - isInitialReverseOrder = true, screenSource = AnalyticsParam.ScreensSources.Markets.value, ), ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt index 857855c019..da2ed27af0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/exchangeservice/swap/ExpressUtils.kt @@ -11,7 +11,7 @@ object ExpressUtils { private const val BATCH_ID_CHANGENOW = "BB000013" private const val BATCH_ID_PARTNER = "AF990015" - fun getRefCode(userWallet: UserWallet, appPreferencesStore: AppPreferencesStore): String? { + fun getRefCode(userWallet: UserWallet?, appPreferencesStore: AppPreferencesStore): String? { return when (userWallet) { is UserWallet.Cold -> { when { diff --git a/data/swap/build.gradle.kts b/data/swap/build.gradle.kts index 8b9fdaffe8..6541bb22cd 100644 --- a/data/swap/build.gradle.kts +++ b/data/swap/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.data.swap" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Core */ implementation(projects.core.datasource) @@ -56,4 +60,10 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(tangemDeps.card.core) + testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index eea56a309b..82196f8a4e 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -13,6 +13,7 @@ import com.tangem.data.swap.converter.TokenInfoConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.express.TangemExpressApi import com.tangem.datasource.api.express.models.request.ExchangeSentRequestBody +import com.tangem.datasource.api.express.models.request.LeastTokenInfo import com.tangem.datasource.api.express.models.request.PairsRequestBody import com.tangem.datasource.api.express.models.response.ExchangeDataResponseWithTxDetails import com.tangem.datasource.api.express.models.response.RateType @@ -34,7 +35,6 @@ import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.swap.SwapRepositoryV2 import com.tangem.domain.swap.models.* -import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.tokens.operations.CryptoCurrencyStatusFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -44,6 +44,7 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import java.io.IOException import java.math.BigDecimal +import java.math.RoundingMode import java.util.UUID import javax.inject.Inject @@ -64,6 +65,76 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( private val exchangeStatusConverter = SwapStatusConverter() private val txDetailsMoshiAdapter = moshi.adapter(TxDetails::class.java) + override suspend fun getPairs( + primarySwapCurrencyStatus: SwapCurrencyStatus, + secondarySwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + swapTxType: SwapTxType, + ): List = withContext(coroutineDispatcher.default) { + val primaryCurrency = primarySwapCurrencyStatus.currency + val secondaryCurrency = secondarySwapCurrencyStatus.currency + val primaryUserWallet = primarySwapCurrencyStatus.userWallet + val secondaryUserWallet = secondarySwapCurrencyStatus.userWallet + + val pairs = awaitAll( + // original pairs + async { + invokePairRequest( + userWallet = primaryUserWallet, + from = listOf(primaryCurrency), + to = listOf(secondaryCurrency), + ) + }, + // reversed pairs + async { + invokePairRequest( + userWallet = secondaryUserWallet, + from = listOf(secondaryCurrency), + to = listOf(primaryCurrency), + ) + }, + ).flatten() + + val expressProviders = expressRepository.getFilteredProviders( + userWallet = primaryUserWallet, + filterProviderTypes = filterProviderTypes, + swapTxType = swapTxType, + ).associateBy(ExpressProvider::providerId) + + fun checkPair(currency: CryptoCurrency, pair: LeastTokenInfo): Boolean { + return currency.getContractAddress() == pair.contractAddress && + currency.network.rawId == pair.network + } + + pairs.mapNotNull { pair -> + val fromCurrencyStatus = when { + checkPair(primaryCurrency, pair.from) -> primarySwapCurrencyStatus.status + checkPair(secondaryCurrency, pair.from) -> secondarySwapCurrencyStatus.status + else -> null + } + + val toCurrencyStatus = when { + checkPair(primaryCurrency, pair.to) -> primarySwapCurrencyStatus.status + checkPair(secondaryCurrency, pair.to) -> secondarySwapCurrencyStatus.status + else -> null + } + + val mappedProviders = pair.providers + .mapNotNull { it.withExpressProvider(expressProviders) } + .filterYieldSupplyProvider(fromCurrencyStatus) + + if (fromCurrencyStatus != null && toCurrencyStatus != null && mappedProviders.isNotEmpty()) { + SwapPairModel( + from = fromCurrencyStatus, + to = toCurrencyStatus, + providers = mappedProviders, + ) + } else { + null + } + } + } + override suspend fun getPairs( userWallet: UserWallet, initialCurrency: CryptoCurrency, @@ -185,16 +256,12 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( ): SwapQuoteModel = withContext(coroutineDispatcher.io) { val response = tangemExpressApi.getExchangeQuote( fromAmount = if (amountType == SwapAmountType.From) { - amount.movePointRight( - fromCryptoCurrency.decimals, - ).toString() + amount.toStringWithRightOffset(fromCryptoCurrency.decimals) } else { null }, toAmount = if (amountType == SwapAmountType.To) { - amount.movePointRight( - toCryptoCurrency.decimals, - ).toString() + amount.toStringWithRightOffset(toCryptoCurrency.decimals) } else { null }, @@ -229,7 +296,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrency: CryptoCurrency, - amount: String, + amount: BigDecimal, amountType: SwapAmountType, toAddress: String, toExtraId: String?, @@ -261,8 +328,16 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( toAddress = toAddress, fromDecimals = fromCurrency.decimals, toDecimals = toCryptoCurrency.decimals, - fromAmount = if (amountType == SwapAmountType.From) amount else null, - toAmount = if (amountType == SwapAmountType.To) amount else null, + fromAmount = if (amountType == SwapAmountType.From) { + amount.toStringWithRightOffset(fromCurrency.decimals) + } else { + null + }, + toAmount = if (amountType == SwapAmountType.To) { + amount.toStringWithRightOffset(toCryptoCurrency.decimals) + } else { + null + }, providerId = expressProvider.providerId, rateType = rateType.name.lowercase(), requestId = requestId, @@ -465,6 +540,10 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( } } + private fun BigDecimal.toStringWithRightOffset(decimals: Int): String { + return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString() + } + private fun List.filterYieldSupplyProvider(cryptoCurrencyStatus: CryptoCurrencyStatus?) = filter { provider -> // !!!WARNING!!! Filter out dex provider if yield supply is active diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt index 37c6ab2dfb..5816ec12b9 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapTransactionRepository.kt @@ -54,7 +54,8 @@ internal class DefaultSwapTransactionRepository( private val userTokensResponseFactory = UserTokensResponseFactory() override suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -75,7 +76,10 @@ internal class DefaultSwapTransactionRepository( val tokenTransactions = savedTransactions ?.firstOrNull { swapTxList -> swapTxList.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, + fromAccountId = fromAccount?.accountId, + toAccountId = toAccount?.accountId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) @@ -89,7 +93,8 @@ internal class DefaultSwapTransactionRepository( mutablePreferences.setObject( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, value = savedTransactions?.updateList( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -97,7 +102,8 @@ internal class DefaultSwapTransactionRepository( transactions = tokenTransactions, ) ?: listOf( listConverter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -123,7 +129,7 @@ internal class DefaultSwapTransactionRepository( ) { savedTransactions, txStatuses, multiAccountList -> val currencyTxs = savedTransactions ?.filter { swapTxList -> - swapTxList.userWalletId == userWallet.walletId.stringValue && + swapTxList.fromUserWalletId == userWallet.walletId.stringValue && ( swapTxList.toCryptoCurrencyId == cryptoCurrencyId.value || swapTxList.fromCryptoCurrencyId == cryptoCurrencyId.value @@ -222,19 +228,27 @@ internal class DefaultSwapTransactionRepository( } } + @Suppress("LongParameterList") private fun SwapTransactionListDTO.checkId( - checkUserWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, + fromAccountId: AccountId?, + toAccountId: AccountId?, fromCurrencyId: CryptoCurrency.ID, toCurrencyId: CryptoCurrency.ID, ): Boolean { - return userWalletId == checkUserWalletId.stringValue && - toCryptoCurrencyId == toCurrencyId.value && - fromCryptoCurrencyId == fromCurrencyId.value + return this.fromUserWalletId == fromUserWalletId.stringValue && + this.toUserWalletId == toUserWalletId.stringValue && + this.fromTokensResponse?.accountId == fromAccountId?.value && + this.toTokensResponse?.accountId == toAccountId?.value && + fromCryptoCurrencyId == fromCurrencyId.value && + toCryptoCurrencyId == toCurrencyId.value } @Suppress("LongParameterList") private fun List.updateList( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -243,7 +257,8 @@ internal class DefaultSwapTransactionRepository( ): List { return addOrReplace( item = listConverter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -252,7 +267,10 @@ internal class DefaultSwapTransactionRepository( ), predicate = { swapTxList -> swapTxList.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, + fromAccountId = fromAccount?.accountId, + toAccountId = toAccount?.accountId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) diff --git a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt index aa9d27b4b7..db7f9ebd06 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/converter/transaction/SavedSwapTransactionListConverter.kt @@ -31,7 +31,8 @@ internal class SavedSwapTransactionListConverter( } override fun convert(value: SwapTransactionListModel) = SwapTransactionListDTO( - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( @@ -79,7 +80,8 @@ internal class SavedSwapTransactionListConverter( txStatuses = txStatuses, ) }, - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromCryptoCurrency = fromCryptoCurrency, @@ -98,14 +100,16 @@ internal class SavedSwapTransactionListConverter( @Suppress("LongParameterList") fun default( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, toAccount: Account?, tokenTransactions: List, ) = SwapTransactionListDTO( - userWalletId = userWalletId.stringValue, + fromUserWalletId = fromUserWalletId.stringValue, + toUserWalletId = toUserWalletId.stringValue, fromCryptoCurrencyId = fromCryptoCurrency.id.value, toCryptoCurrencyId = toCryptoCurrency.id.value, fromTokensResponse = userTokensResponseFactory.createResponseToken( diff --git a/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt b/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt index ab4738e367..d928a75938 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/models/SwapTransactionListDTO.kt @@ -9,7 +9,9 @@ import java.math.BigDecimal @JsonClass(generateAdapter = true) internal data class SwapTransactionListDTO( @Json(name = "userWalletId") - val userWalletId: String, + val fromUserWalletId: String, + @Json(name = "toUserWalletId") + val toUserWalletId: String = fromUserWalletId, @Json(name = "fromCryptoCurrencyId") val fromCryptoCurrencyId: String, @Json(name = "toCryptoCurrencyId") diff --git a/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt b/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt new file mode 100644 index 0000000000..92343937bb --- /dev/null +++ b/data/swap/src/test/kotlin/com/tangem/data/swap/DefaultSwapRepositoryV2Test.kt @@ -0,0 +1,709 @@ +package com.tangem.data.swap + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.express.TangemExpressApi +import com.tangem.datasource.api.express.models.request.LeastTokenInfo +import com.tangem.datasource.api.express.models.request.PairsRequestBody +import com.tangem.datasource.api.express.models.response.* +import com.tangem.datasource.crypto.DataSignatureVerifier +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.domain.express.ExpressRepository +import com.tangem.domain.express.models.* +import com.tangem.domain.express.models.ExpressError +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.wallet.UserWallet +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.quotes.single.SingleQuoteStatusFetcher +import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier +import com.tangem.domain.swap.models.SwapAmountType +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapStatus +import com.tangem.domain.swap.models.SwapTxType +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.assertThrows +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultSwapRepositoryV2Test { + + private val tangemExpressApi: TangemExpressApi = mockk() + private val expressRepository: ExpressRepository = mockk() + private val appPreferencesStore: AppPreferencesStore = mockk(relaxed = true) + private val dataSignatureVerifier: DataSignatureVerifier = mockk() + private val singleQuoteStatusSupplier: SingleQuoteStatusSupplier = mockk() + private val singleQuoteStatusFetcher: SingleQuoteStatusFetcher = mockk() + private val moshi: Moshi = Moshi.Builder().build() + + private val repository = DefaultSwapRepositoryV2( + tangemExpressApi = tangemExpressApi, + expressRepository = expressRepository, + coroutineDispatcher = TestingCoroutineDispatcherProvider(), + appPreferencesStore = appPreferencesStore, + dataSignatureVerifier = dataSignatureVerifier, + singleQuoteStatusSupplier = singleQuoteStatusSupplier, + singleQuoteStatusFetcher = singleQuoteStatusFetcher, + moshi = moshi, + ) + + @BeforeEach + fun resetMocks() { + clearMocks( + tangemExpressApi, + expressRepository, + appPreferencesStore, + dataSignatureVerifier, + singleQuoteStatusSupplier, + singleQuoteStatusFetcher, + ) + } + + // region getPairs(SwapCurrencyStatus, SwapCurrencyStatus) + + @Test + fun `getPairs with SwapCurrencyStatus returns mapped pairs when providers match`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).hasSize(2) + assertThat(result.first().from).isEqualTo(primaryStatus) + assertThat(result.first().to).isEqualTo(secondaryStatus) + assertThat(result.first().providers).hasSize(1) + assertThat(result.first().providers.first().providerId).isEqualTo(PROVIDER_ID) + } + + @Test + fun `getPairs with SwapCurrencyStatus returns empty when no providers match`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = "unknown-provider", rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `getPairs with SwapCurrencyStatus returns empty when API returns empty pairs`() = runTest { + // Arrange + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = createCryptoCurrencyStatus(primaryCoin), + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = createCryptoCurrencyStatus(secondaryCoin), + account = mockk(), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(emptyList()) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).isEmpty() + } + + // endregion + + // region getPairs(UserWallet, CryptoCurrency, List) + + @Test + fun `getPairs with currency status list returns mapped pairs`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + userWallet = userWallet, + initialCurrency = primaryCoin, + cryptoCurrencyStatusList = listOf(primaryStatus, secondaryStatus), + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert + assertThat(result).hasSize(2) + assertThat(result.first().from).isEqualTo(primaryStatus) + assertThat(result.first().to).isEqualTo(secondaryStatus) + } + + @Test + fun `getPairs with SendWithSwap only fetches forward pairs`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatus(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf(SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT))), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(expressProvider) + + // Act + val result = repository.getPairs( + userWallet = userWallet, + initialCurrency = primaryCoin, + cryptoCurrencyStatusList = listOf(primaryStatus, secondaryStatus), + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.SendWithSwap, + ) + + // Assert + assertThat(result).hasSize(1) + + // SendWithSwap should call getPairs only once (forward), not twice (forward + reverse) + coVerify(exactly = 1) { tangemExpressApi.getPairs(any(), any(), any()) } + } + + // endregion + + // region getSwapQuote + + @Test + fun `getSwapQuote returns correct quote model for fromAmount`() = runTest { + // Arrange + val quoteResponse = ExchangeQuoteResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + allowanceContract = "0xAllowance", + minAmount = BigDecimal.ONE, + quoteId = "quote-123", + ) + + coEvery { + tangemExpressApi.getExchangeQuote( + fromAmount = any(), + toAmount = any(), + fromNetwork = any(), + fromContractAddress = any(), + fromDecimals = any(), + toNetwork = any(), + toContractAddress = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + userWalletId = any(), + refCode = any(), + ) + } returns ApiResponse.Success(quoteResponse) + + // Act + val result = repository.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = primaryCoin, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal.ONE, + amountType = SwapAmountType.From, + provider = expressProvider, + rateType = ExpressRateType.Float, + ) + + // Assert + assertThat(result.provider).isEqualTo(expressProvider) + assertThat(result.toTokenAmount).isEqualTo(BigDecimal("1.00000000")) + assertThat(result.fromTokenAmount).isEqualTo(BigDecimal("1.000000000000000000")) + assertThat(result.allowanceContract).isEqualTo("0xAllowance") + assertThat(result.quoteId).isEqualTo("quote-123") + } + + @Test + fun `getSwapQuote sends fromAmount when amountType is From`() = runTest { + // Arrange + val quoteResponse = ExchangeQuoteResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + allowanceContract = null, + minAmount = BigDecimal.ONE, + quoteId = null, + ) + + coEvery { + tangemExpressApi.getExchangeQuote( + fromAmount = any(), + toAmount = any(), + fromNetwork = any(), + fromContractAddress = any(), + fromDecimals = any(), + toNetwork = any(), + toContractAddress = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + userWalletId = any(), + refCode = any(), + ) + } returns ApiResponse.Success(quoteResponse) + + // Act + repository.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = primaryCoin, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal("2.5"), + amountType = SwapAmountType.From, + provider = expressProvider, + rateType = ExpressRateType.Float, + ) + + // Assert — fromAmount is set, toAmount is null + coVerify { + tangemExpressApi.getExchangeQuote( + fromAmount = "2500000000000000000", + toAmount = null, + fromNetwork = ETH_BACKEND_ID, + fromContractAddress = "0", + fromDecimals = 18, + toNetwork = BTC_BACKEND_ID, + toContractAddress = "0", + toDecimals = 8, + providerId = PROVIDER_ID, + rateType = "float", + userWalletId = any(), + refCode = any(), + ) + } + } + + @Test + fun `getSwapQuote sends toAmount when amountType is To`() = runTest { + // Arrange + val quoteResponse = ExchangeQuoteResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + allowanceContract = null, + minAmount = BigDecimal.ONE, + quoteId = null, + ) + + coEvery { + tangemExpressApi.getExchangeQuote( + fromAmount = any(), + toAmount = any(), + fromNetwork = any(), + fromContractAddress = any(), + fromDecimals = any(), + toNetwork = any(), + toContractAddress = any(), + toDecimals = any(), + providerId = any(), + rateType = any(), + userWalletId = any(), + refCode = any(), + ) + } returns ApiResponse.Success(quoteResponse) + + // Act + repository.getSwapQuote( + userWallet = userWallet, + fromCryptoCurrency = primaryCoin, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal("1.5"), + amountType = SwapAmountType.To, + provider = expressProvider, + rateType = ExpressRateType.Fixed, + ) + + // Assert — toAmount is set, fromAmount is null + coVerify { + tangemExpressApi.getExchangeQuote( + fromAmount = null, + toAmount = "150000000", + fromNetwork = ETH_BACKEND_ID, + fromContractAddress = "0", + fromDecimals = 18, + toNetwork = BTC_BACKEND_ID, + toContractAddress = "0", + toDecimals = 8, + providerId = PROVIDER_ID, + rateType = "fixed", + userWalletId = any(), + refCode = any(), + ) + } + } + + // endregion + + // region getExchangeStatus + + @Test + fun `getExchangeStatus returns converted status model`() = runTest { + // Arrange + val statusResponse = ExchangeStatusResponse( + providerId = PROVIDER_ID, + status = ExchangeStatus.Finished, + externalTxId = "ext-tx-1", + externalTxUrl = "https://example.com/tx/1", + error = null, + ) + + coEvery { + tangemExpressApi.getExchangeStatus(any(), any(), any()) + } returns ApiResponse.Success(statusResponse) + + // Act + val result = repository.getExchangeStatus(userWallet = userWallet, txId = "tx-123") + + // Assert + assertThat(result.providerId).isEqualTo(PROVIDER_ID) + assertThat(result.status).isEqualTo(SwapStatus.Finished) + assertThat(result.txId).isEqualTo("ext-tx-1") + assertThat(result.txExternalUrl).isEqualTo("https://example.com/tx/1") + } + + // endregion + + // region swapTransactionSent + + @Test + fun `swapTransactionSent calls exchangeSent API`() = runTest { + // Arrange + val fromStatus = createCryptoCurrencyStatus(primaryCoin) + + coEvery { + tangemExpressApi.exchangeSent(any(), any(), any()) + } returns ApiResponse.Success(ExchangeSentResponseBody(txId = "tx-1", status = "ok")) + + // Act + repository.swapTransactionSent( + userWallet = userWallet, + fromCryptoCurrencyStatus = fromStatus, + payInAddress = "0xPayIn", + txId = "tx-1", + txHash = "0xHash", + txExtraId = null, + ) + + // Assert + coVerify { + tangemExpressApi.exchangeSent( + userWalletId = any(), + refCode = any(), + body = match { body -> + body.txId == "tx-1" && + body.txHash == "0xHash" && + body.payinAddress == "0xPayIn" && + body.payinExtraId == null + }, + ) + } + } + + // endregion + + // region filterYieldSupplyProvider + + @Test + fun `getPairs filters out DEX providers when yield supply is active`() = runTest { + // Arrange + val primaryStatus = createCryptoCurrencyStatusWithActiveYield(primaryCoin) + val secondaryStatus = createCryptoCurrencyStatus(secondaryCoin) + val primarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = primaryStatus, + account = mockk(), + ) + val secondarySwapCurrencyStatus = SwapCurrencyStatus( + userWallet = userWallet, + status = secondaryStatus, + account = mockk(), + ) + + val swapPair = SwapPair( + from = LeastTokenInfo(contractAddress = "0", network = ETH_BACKEND_ID), + to = LeastTokenInfo(contractAddress = "0", network = BTC_BACKEND_ID), + providers = listOf( + SwapPairProvider(providerId = PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)), + SwapPairProvider(providerId = CEX_PROVIDER_ID, rateTypes = listOf(RateType.FLOAT)), + ), + ) + + coEvery { + tangemExpressApi.getPairs(any(), any(), any()) + } returns ApiResponse.Success(listOf(swapPair)) + + coEvery { + expressRepository.getProviders(any(), any()) + } returns listOf(dexProvider, cexProvider) + + // Act + val result = repository.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = emptyList(), + swapTxType = SwapTxType.Swap, + ) + + // Assert — only CEX provider should remain + assertThat(result).hasSize(2) + val providers = result.first().providers + assertThat(providers).hasSize(1) + assertThat(providers.first().type).isEqualTo(ExpressProviderType.CEX) + } + + // endregion + + // region getSwapData + + @Test + fun `getSwapData throws InvalidSignatureError when signature verification fails`() = runTest { + // Arrange + val fromStatus = createCryptoCurrencyStatus(primaryCoin) + val exchangeDataResponse = ExchangeDataResponse( + fromAmount = "1000000000000000000", + fromDecimals = 18, + toAmount = "100000000", + toDecimals = 8, + txId = "tx-123", + txDetailsJson = "{}", + signature = "invalid-sig", + ) + + coEvery { + tangemExpressApi.getExchangeData( + fromContractAddress = any(), + toContractAddress = any(), + fromNetwork = any(), + toNetwork = any(), + fromAddress = any(), + toAddress = any(), + fromDecimals = any(), + toDecimals = any(), + fromAmount = any(), + toAmount = any(), + providerId = any(), + rateType = any(), + requestId = any(), + refundAddress = any(), + refundExtraId = any(), + userWalletId = any(), + partnerOperationType = any(), + refCode = any(), + toExtraId = any(), + quoteId = any(), + ) + } returns ApiResponse.Success(exchangeDataResponse) + + every { dataSignatureVerifier.verifySignature(any(), any()) } returns false + + // Act & Assert + assertThrows { + repository.getSwapData( + userWallet = userWallet, + fromCryptoCurrencyStatus = fromStatus, + toCryptoCurrency = secondaryCoin, + amount = BigDecimal.ONE, + amountType = SwapAmountType.From, + toAddress = "0xToAddress", + toExtraId = null, + expressProvider = cexProvider, + rateType = ExpressRateType.Float, + expressOperationType = ExpressOperationType.SWAP, + quoteId = null, + ) + } + } + + // endregion + + private companion object { + const val ETH_BACKEND_ID = "ethereum" + const val BTC_BACKEND_ID = "bitcoin" + const val PROVIDER_ID = "dex-provider-1" + const val CEX_PROVIDER_ID = "cex-provider-1" + + val userWallet: UserWallet = MockUserWalletFactory.create() + + val ethNetwork: Network = mockk(relaxed = true) { + every { rawId } returns ETH_BACKEND_ID + } + + val btcNetwork: Network = mockk(relaxed = true) { + every { rawId } returns BTC_BACKEND_ID + } + + val primaryCoin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { network } returns ethNetwork + every { decimals } returns 18 + every { id } returns mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID("ethereum") + } + } + + val secondaryCoin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { network } returns btcNetwork + every { decimals } returns 8 + every { id } returns mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID("bitcoin") + } + } + + val expressProvider = ExpressProvider( + providerId = PROVIDER_ID, + rateTypes = listOf(ExpressRateType.Float), + name = "Test DEX", + type = ExpressProviderType.DEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + val dexProvider = expressProvider + + val cexProvider = ExpressProvider( + providerId = CEX_PROVIDER_ID, + rateTypes = listOf(ExpressRateType.Float), + name = "Test CEX", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + fun createCryptoCurrencyStatus(currency: CryptoCurrency): CryptoCurrencyStatus { + val value: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { yieldSupplyStatus } returns null + every { networkAddress } returns mockk(relaxed = true) { + every { defaultAddress } returns mockk(relaxed = true) { + every { value } returns "0xAddress" + } + } + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + + fun createCryptoCurrencyStatusWithActiveYield(currency: CryptoCurrency): CryptoCurrencyStatus { + val yieldStatus: YieldSupplyStatus = mockk { + every { isActive } returns true + } + val value: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { yieldSupplyStatus } returns yieldStatus + every { networkAddress } returns mockk(relaxed = true) { + every { defaultAddress } returns mockk(relaxed = true) { + every { value } returns "0xAddress" + } + } + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + } +} \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt index 6d3effb0c0..ce4ddfd9dd 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressError.kt @@ -2,6 +2,7 @@ package com.tangem.domain.express.models import java.math.BigDecimal +@Suppress("MagicNumber") sealed class ExpressError : Throwable() { abstract val code: Int @@ -61,4 +62,12 @@ sealed class ExpressError : Throwable() { data object UnknownError : ExpressError() { override val code: Int = -1 } + + data class TooLargeSolanaTransactionError(override val code: Int = -2) : ExpressError() { + override val message: String = "tooLargeSolanaTransaction" + } + + data class DexActiveSupplyError(override val code: Int = -3) : ExpressError() { + override val message: String = "dexActiveSupplyError" + } } \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt index 2974c8a2db..74e05c2a7c 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressProviderType.kt @@ -23,14 +23,18 @@ enum class ExpressProviderType(val typeName: String) { ONRAMP(typeName = "ONRAMP"), ; + fun shouldStoreSwapTransaction() = when (this) { + CEX, + DEX_BRIDGE, + DEX, + -> true + ONRAMP, + -> false + } + companion object { - fun ExpressProviderType.shouldStoreSwapTransaction() = when (this) { - CEX, - DEX_BRIDGE, - DEX, - -> true - ONRAMP, - -> false + fun getSwapProviderTypes(): List { + return listOf(CEX, DEX, DEX_BRIDGE) } } } \ No newline at end of file diff --git a/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt b/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt index 087b4312ed..cfcb5fc1d6 100644 --- a/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt +++ b/domain/legacy/src/main/java/com/tangem/domain/exchange/RampStateManager.kt @@ -31,11 +31,22 @@ interface RampStateManager { sendUnavailabilityReason: ScenarioUnavailabilityReason?, ): Either + /** + * Check if [CryptoCurrency] is available for swap (express/assets request) + * + * @param userWalletId the ID of the user's wallet + * @param cryptoCurrency cryptocurrency + */ suspend fun availableForSwap( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): ScenarioUnavailabilityReason + suspend fun availableForSwap( + userWalletId: UserWalletId, + cryptoCurrencies: List, + ): Map + suspend fun fetchSellServiceData() fun getSellInitializationStatus(): Flow> diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt new file mode 100644 index 0000000000..4a0067251f --- /dev/null +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapCurrencyStatus.kt @@ -0,0 +1,33 @@ +package com.tangem.domain.swap.models + +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.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId + +/** + * Represents the status of a cryptocurrency in the context of a swap operation. + * + * Combines the [UserWallet], [CryptoCurrencyStatus], and [Account] to provide + * all necessary information about a currency participating in a swap. + * + * @property userWallet the user wallet that owns the currency + * @property status the current status of the cryptocurrency, including balance and value state + * @property account the account within the wallet that holds the currency + * @property currency shortcut to the [CryptoCurrency] from [status] + * @property userWalletId shortcut to the wallet ID from [userWallet] + * @property isAvailableForSwap whether this currency can participate in a swap operation, + * determined by [RampStateManager][com.tangem.domain.exchange.RampStateManager] + */ +data class SwapCurrencyStatus( + val userWallet: UserWallet, + val status: CryptoCurrencyStatus, + val account: Account, + val isAvailableForSwap: Boolean = true, +) { + val currency: CryptoCurrency + get() = status.currency + val userWalletId: UserWalletId + get() = userWallet.walletId +} \ No newline at end of file diff --git a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt index 5c8e52d327..cbaf128a59 100644 --- a/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt +++ b/domain/swap/models/src/main/java/com/tangem/domain/swap/models/SwapTransactionListModel.kt @@ -9,7 +9,8 @@ import java.math.BigDecimal * List of saved swap transactions */ data class SwapTransactionListModel( - val userWalletId: String, + val fromUserWalletId: String, + val toUserWalletId: String, val fromCryptoCurrencyId: String, val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt index dacec2c058..f852d6c8cc 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapRepositoryV2.kt @@ -16,6 +16,21 @@ import java.math.BigDecimal @Suppress("LongParameterList") interface SwapRepositoryV2 { + /** + * Returns express swap pairs for a specific primary and secondary currency. + * + * @param primarySwapCurrencyStatus primary currency status participating in the swap + * @param secondarySwapCurrencyStatus secondary currency status participating in the swap + * @param filterProviderTypes filters only specified provider types, if empty returns providers as is + * @param swapTxType swap tx type + */ + suspend fun getPairs( + primarySwapCurrencyStatus: SwapCurrencyStatus, + secondarySwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + swapTxType: SwapTxType, + ): List + /** * Express swap pairs, both direct and reversed * @@ -84,7 +99,7 @@ interface SwapRepositoryV2 { userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrency: CryptoCurrency, - amount: String, + amount: BigDecimal, amountType: SwapAmountType, toAddress: String, toExtraId: String?, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt index 3e22c172a7..4b450ef0b8 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/SwapTransactionRepository.kt @@ -18,7 +18,8 @@ interface SwapTransactionRepository { /** * Store new swap transaction * - * @param userWalletId selected user wallet id + * @param fromUserWalletId wallet id swap from + * @param toUserWalletId wallet id swap to * @param fromCryptoCurrency currency swap from * @param toCryptoCurrency currency swap to * @param fromAccount account swap from @@ -27,7 +28,8 @@ interface SwapTransactionRepository { */ @Suppress("LongParameterList") suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt index 2726e77e94..616afe33e8 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapDataUseCase.kt @@ -12,6 +12,7 @@ import com.tangem.domain.swap.SwapErrorResolver import com.tangem.domain.swap.SwapRepositoryV2 import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDataModel +import java.math.BigDecimal @Suppress("LongParameterList") class GetSwapDataUseCase( @@ -22,7 +23,7 @@ class GetSwapDataUseCase( suspend operator fun invoke( userWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, - amount: String, + amount: BigDecimal, amountType: SwapAmountType, toCryptoCurrency: CryptoCurrency, toAddress: String, diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt new file mode 100644 index 0000000000..101edf46e2 --- /dev/null +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/GetSwapPairUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.swap.usecase + +import arrow.core.Either +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.swap.SwapErrorResolver +import com.tangem.domain.swap.SwapRepositoryV2 +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.swap.models.SwapTxType + +/** + * Use case for retrieving swap pairs between a specific primary and secondary currency. + * + * Returns either a list of available swap pairs or a resolved swap error. + * + * @property swapRepositoryV2 repository providing swap pair data + * @property swapErrorResolver resolver that maps exceptions to domain swap errors + */ +class GetSwapPairUseCase( + private val swapRepositoryV2: SwapRepositoryV2, + private val swapErrorResolver: SwapErrorResolver, +) { + suspend operator fun invoke( + primarySwapCurrencyStatus: SwapCurrencyStatus, + secondarySwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + swapTxType: SwapTxType, + ) = Either.catch { + swapRepositoryV2.getPairs( + primarySwapCurrencyStatus = primarySwapCurrencyStatus, + secondarySwapCurrencyStatus = secondarySwapCurrencyStatus, + filterProviderTypes = filterProviderTypes, + swapTxType = swapTxType, + ) + }.mapLeft(swapErrorResolver::resolve) +} \ No newline at end of file diff --git a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt index 70c9811d57..b7ed209339 100644 --- a/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt +++ b/domain/swap/src/main/java/com/tangem/domain/swap/usecase/SwapTransactionSentUseCase.kt @@ -2,7 +2,6 @@ package com.tangem.domain.swap.usecase import arrow.core.Either import com.tangem.domain.express.models.ExpressProvider -import com.tangem.domain.express.models.ExpressProviderType.Companion.shouldStoreSwapTransaction import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -19,7 +18,8 @@ class SwapTransactionSentUseCase( ) { suspend operator fun invoke( - userWallet: UserWallet, + fromUserWallet: UserWallet, + toUserWallet: UserWallet, fromCryptoCurrencyStatus: CryptoCurrencyStatus, toCryptoCurrencyStatus: CryptoCurrencyStatus, fromAccount: Account?, @@ -33,7 +33,8 @@ class SwapTransactionSentUseCase( ) = Either.catch { if (provider.type.shouldStoreSwapTransaction()) { swapTransactionRepository.storeTransaction( - userWalletId = userWallet.walletId, + fromUserWalletId = fromUserWallet.walletId, + toUserWalletId = toUserWallet.walletId, fromCryptoCurrency = fromCryptoCurrencyStatus.currency, toCryptoCurrency = toCryptoCurrencyStatus.currency, fromAccount = fromAccount, @@ -58,11 +59,11 @@ class SwapTransactionSentUseCase( } swapTransactionRepository.storeLastSwappedCryptoCurrencyId( - userWalletId = userWallet.walletId, + userWalletId = fromUserWallet.walletId, cryptoCurrencyId = toCryptoCurrencyStatus.currency.id, ) swapRepositoryV2.swapTransactionSent( - userWallet = userWallet, + userWallet = fromUserWallet, fromCryptoCurrencyStatus = fromCryptoCurrencyStatus, payInAddress = payInAddress, txId = swapDataTransactionModel.txId, diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt index a5ee00426b..73d73a599f 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/actions/CommonActionsFactory.kt @@ -3,14 +3,11 @@ package com.tangem.domain.tokens.actions import com.tangem.domain.exchange.RampStateManager 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.staking.model.StakingAvailability import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState.ActionState -import com.tangem.domain.transaction.models.AssetRequirementsCondition import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.models.YieldSupplyAvailability -import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -65,20 +62,6 @@ internal class CommonActionsFactory( getSendUnavailabilityReason(userWalletId = userWallet.walletId, cryptoCurrencyStatus = cryptoCurrencyStatus) } - val swapUnavailabilityReason = if (!cryptoCurrencyStatus.currency.isCustom && - cryptoCurrencyStatus.value !is CryptoCurrencyStatus.NoQuote - ) { - async { - getSwapUnavailabilityReason( - userWalletId = userWallet.walletId, - currencyStatus = cryptoCurrencyStatus, - requirementsDeferred = requirementsDeferred, - ) - } - } else { - null - } - val hideTokenUnavailabilityReason = getTokenHideUnavailabilityReason(userWallet) actionAvailabilityBuilder { @@ -111,7 +94,6 @@ internal class CommonActionsFactory( createSwapAction( userWallet = userWallet, cryptoCurrencyStatus = cryptoCurrencyStatus, - swapUnavailableReasonDeferred = swapUnavailabilityReason, shouldShowSwapStories = shouldShowSwapStories, ).addByReason() // endregion @@ -140,11 +122,9 @@ internal class CommonActionsFactory( } } - @Suppress("CanBeNonNullable") - private suspend fun createSwapAction( + private fun createSwapAction( userWallet: UserWallet, cryptoCurrencyStatus: CryptoCurrencyStatus, - swapUnavailableReasonDeferred: Deferred?, shouldShowSwapStories: Boolean, ): ActionState { val cryptoCurrency = cryptoCurrencyStatus.currency @@ -172,35 +152,11 @@ internal class CommonActionsFactory( ) } else -> { - val reason = requireNotNull(swapUnavailableReasonDeferred) { - "swapUnavailableReasonDeferred must not be null for available swap action" - }.await() - - return ActionState.Swap( - unavailabilityReason = reason, - shouldShowBadge = reason == ScenarioUnavailabilityReason.None && shouldShowSwapStories, + ActionState.Swap( + unavailabilityReason = ScenarioUnavailabilityReason.None, + shouldShowBadge = shouldShowSwapStories, ) } } } - - private suspend fun getSwapUnavailabilityReason( - userWalletId: UserWalletId, - currencyStatus: CryptoCurrencyStatus, - requirementsDeferred: Deferred?, - ): ScenarioUnavailabilityReason { - val swapUnavailabilityReason = rampStateManager - .availableForSwap(userWalletId = userWalletId, cryptoCurrency = currencyStatus.currency) - val shouldCheckAssetRequirements = - swapUnavailabilityReason == ScenarioUnavailabilityReason.None && requirementsDeferred != null - - val yieldSupplyStatus = currencyStatus.value.yieldSupplyStatus - val isUnavailableByYieldSupply = yieldSupplyStatus?.isAllowedToSpend == false && yieldSupplyStatus.isActive - - return when { - isUnavailableByYieldSupply -> ScenarioUnavailabilityReason.YieldSupplyApprovalRequired - shouldCheckAssetRequirements -> getReceiveScenario(requirementsDeferred.await()) - else -> swapUnavailabilityReason - } - } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt index 4200d87d77..acc42bd153 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/GetPaymentAccountCryptoCurrencyStatusUseCase.kt @@ -9,13 +9,32 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull class GetPaymentAccountCryptoCurrencyStatusUseCase( private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, ) { - suspend operator fun invoke( + operator fun invoke( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + ): Flow> { + return paymentAccountStatusSupplier(userWalletId).mapNotNull { accountStatus -> + val cryptoCurrencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + else -> return@mapNotNull null + } + if (cryptoCurrencyStatus.currency == cryptoCurrency) { + accountStatus.account to cryptoCurrencyStatus + } else { + null + } + } + } + + suspend fun invokeSync( userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency, ): Option> { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt index 584aadf822..11a144fc73 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/deeplink/DefaultSwapDeepLinkHandler.kt @@ -2,10 +2,11 @@ package com.tangem.features.onramp.deeplink import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.utils.logging.TangemLogger import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import com.tangem.utils.logging.TangemLogger internal class DefaultSwapDeepLinkHandler @AssistedInject constructor( router: AppRouter, @@ -19,7 +20,12 @@ internal class DefaultSwapDeepLinkHandler @AssistedInject constructor( TangemLogger.e("Error on getting user wallet: $it") }, ifRight = { userWallet -> - router.push(AppRoute.SwapCrypto(userWallet.walletId)) + router.push( + AppRoute.Swap( + userWalletId = userWallet.walletId, + screenSource = AnalyticsParam.ScreensSources.Main.value, + ), + ) }, ) } diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt index cf05eb89cc..876a6aa1dc 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt @@ -114,8 +114,7 @@ internal class SwapSelectTokensModel @Inject constructor( router.push( route = AppRoute.Swap( - currencyFrom = requireNotNull(fromCurrencyStatus.value).currency, - currencyTo = status.currency, + cryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency, userWalletId = params.userWalletId, screenSource = AnalyticsParam.ScreensSources.Main.value, ), diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index 884058c33b..b11f85dce7 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -309,10 +309,7 @@ internal class OnrampTokenListModel @Inject constructor( ).isRight() } OnrampOperation.SWAP -> { - val isAvailable = rampStateManager.availableForSwap( - userWalletId = params.userWalletId, - cryptoCurrency = status.currency, - ).isAvailable() && !status.currency.isCustom + val isAvailable = !status.currency.isCustom val supplyStatus = status.value.yieldSupplyStatus val isUnavailableByYieldSupply = supplyStatus?.isAllowedToSpend == false && supplyStatus.isActive diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt index 21709e238a..7057fd13d9 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/subcomponents/amount/model/SendAmountModel.kt @@ -20,13 +20,11 @@ import com.tangem.core.ui.extensions.WrappedList import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase -import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.send.v2.api.entity.PredefinedValues @@ -44,7 +42,6 @@ import com.tangem.utils.extensions.orZero import com.tangem.utils.isNullOrZero import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.properties.Delegates @@ -61,7 +58,6 @@ internal class SendAmountModel @Inject constructor( private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getUserWalletUseCase: GetUserWalletUseCase, - private val rampStateManager: RampStateManager, private val sendAmountAlertFactory: SendAmountAlertFactory, private val getWalletsUseCase: GetWalletsUseCase, ) : Model(), SendAmountClickIntents { @@ -73,7 +69,6 @@ internal class SendAmountModel @Inject constructor( private val _uiState = MutableStateFlow(params.state) val uiState = _uiState.asStateFlow() - private var isAvailableForSwap: Boolean = false val isSendWithSwapAvailable: StateFlow field = MutableStateFlow(false) @@ -87,12 +82,6 @@ internal class SendAmountModel @Inject constructor( private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull() init { - modelScope.launch { - isAvailableForSwap = rampStateManager.availableForSwap( - userWalletId = params.userWalletId, - cryptoCurrency = params.cryptoCurrency, - ) == ScenarioUnavailabilityReason.None - } configAmountNavigation() initAppCurrency() subscribeOnCryptoCurrencyStatusFlow() @@ -409,7 +398,7 @@ internal class SendAmountModel @Inject constructor( val isMultiCurrency = userWallet?.isMultiCurrency == true isSendWithSwapAvailable.update { - isAvailableForSwap && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr + !params.cryptoCurrency.isCustom && isMultiCurrency && !params.predefinedValues.isFromMainScreenQr } } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt index 9f9d5ff8da..e35f01fcf3 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt @@ -26,12 +26,11 @@ import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.send.v2.api.subcomponents.feeSelector.utils.FeeCalculationUtils import com.tangem.features.swap.v2.impl.common.ConfirmData import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal -import java.math.RoundingMode @Suppress("LongParameterList") internal class SwapTransactionSender @AssistedInject constructor( @@ -91,7 +90,7 @@ internal class SwapTransactionSender @AssistedInject constructor( val feeValue = confirmData.fee?.amount?.value ?: return val destination = confirmData.enteredDestination ?: return - val (swapDataRequestAmount, swapDataRequestCurrency) = when (confirmData.amountType) { + val swapDataRequestAmount = when (confirmData.amountType) { SwapAmountType.From -> { val amountValue = confirmData.enteredFromAmount ?: return val subtracted = FeeCalculationUtils.checkAndCalculateSubtractedAmount( @@ -101,11 +100,11 @@ internal class SwapTransactionSender @AssistedInject constructor( feeValue = feeValue, reduceAmountBy = confirmData.reduceAmountBy, ) - subtracted to fromStatus + subtracted } SwapAmountType.To -> { val amountValue = confirmData.enteredToAmount ?: return - amountValue to toStatus + amountValue } } @@ -117,7 +116,7 @@ internal class SwapTransactionSender @AssistedInject constructor( val swapData = getSwapDataUseCase( userWallet = userWallet, fromCryptoCurrencyStatus = fromStatus, - amount = swapDataRequestAmount.toStringWithRightOffset(swapDataRequestCurrency.currency.decimals), + amount = swapDataRequestAmount, amountType = confirmData.amountType, toCryptoCurrency = toStatus.currency, toAddress = destination, @@ -208,7 +207,8 @@ internal class SwapTransactionSender @AssistedInject constructor( ifRight = { txHash -> val timestamp = System.currentTimeMillis() swapTransactionSentUseCase.invoke( - userWallet = userWallet, + fromUserWallet = userWallet, + toUserWallet = userWallet, fromCryptoCurrencyStatus = fromStatus, toCryptoCurrencyStatus = toStatus, fromAccount = fromAccount, @@ -225,10 +225,6 @@ internal class SwapTransactionSender @AssistedInject constructor( ) } - private fun BigDecimal.toStringWithRightOffset(decimals: Int): String { - return setScale(decimals, RoundingMode.HALF_DOWN).movePointRight(decimals).toPlainString() - } - @AssistedFactory interface Factory { fun create(userWallet: UserWallet): SwapTransactionSender diff --git a/features/swap/CLAUDE.md b/features/swap/CLAUDE.md new file mode 100644 index 0000000000..c4f75d64f1 --- /dev/null +++ b/features/swap/CLAUDE.md @@ -0,0 +1,148 @@ +# Swap Feature + +Token-to-token exchange feature. Users select FROM and TO tokens, get quotes from providers (DEX/CEX), approve ERC-20 allowances if needed, and execute swaps. + +## Module Structure + +``` +features/swap/ + api/ — Public contracts (SwapComponent, SwapEntryComponent, SwapFeatureToggles) + impl/ — UI, model, navigation, DI, token selection subfeature + domain/ — Business logic (SwapInteractor) + domain models + api/ — Domain interfaces + models/ — Domain model types (SwapPair, SwapProvider, SwapState, etc.) + data/ — Repository implementations, Retrofit APIs, Moshi DTOs +``` + +**Package naming:** API = `com.tangem.features.swap`, Impl = `com.tangem.feature.swap` (singular `feature`, legacy inconsistency). + +## Key Components + +### SwapComponent (API) +Entry point. `Params` requires `currencyFrom`, `userWalletId`, `screenSource`. Optional: `currencyTo`, `isInitialReverseOrder`, `tangemPayInput`, `preselectedToToken`, `preselectedAccount`. + +### SwapEntryComponent (API) +Gateway component with sealed `Params`: `Story`, `Empty`, `Selected`, `Payment`. Routes to stories or directly to swap based on input type. See `entry/SwapEntryRoute.kt` for route definitions. + +### DefaultSwapComponent (impl) +Decompose component. Creates `SwapModel` via `getOrCreateModel(params)`. + +**Child navigation:** +- `childStack(SwapRoute)` for screen navigation — `SwapRoute.Main`, `SwapRoute.Success`, `SwapRoute.SelectToken(isFromDirection)` rendered via `Children` composable with fade animation +- `SlotNavigation` for approval bottom sheet (`GiveApprovalComponent`) +- `SlotNavigation` for fee selector block + +**Injected factories:** `SwapFeeSelectorBlockComponent.Factory`, `GiveApprovalComponent.Factory`, `ChooseTokenComponent.Factory`. + +### SwapModel (impl) +`@ModelScoped`, extends `Model()`. The central coordinator — ~1500 lines. + +**Key state:** +- `dataStateStateFlow: MutableStateFlow` — reactive domain data (from/to tokens, pairs, providers, amounts, fees) +- `uiState: SwapStateHolder by mutableStateOf()` — Compose UI state built by `StateBuilder` +- `feeSelectorRepository: FeeSelectorRepository` — fee state management +- `stackNavigation: StackNavigation` — stack navigation exposed from `SwapRouter` +- `approvalSlotNavigation: SlotNavigation` — approval bottom sheet + +**Navigation:** +- `SwapRouter` wraps `AppRouter` + `StackNavigation` for screen switching and back navigation +- `swapRouter.openScreen(SwapRoute.SelectToken(isFromDirection))` to push token selection +- `swapRouter.openScreen(SwapRoute.Success)` replaces current with success screen +- `swapRouter.back()` — pops local stack or exits swap via AppRouter + +**Initialization flow (init block):** +1. Subscribes to `chooseTokenBridge.onCurrencyChosen` → `onTokenSelect(result)` +2. Subscribes to `chooseTokenBridge.onClose` → pops slot navigation +3. Checks `ShouldShowStoriesUseCase` → pushes `AppRoute.Stories` if first-time swap +4. Resolves user country for FCA restrictions +5. Loads primary account status, initial currencies, and starts swap pair loading + +**Token selection flow:** +1. User taps FROM or TO card → `onSelectTokenClick(direction)` pushes `SwapRoute.SelectToken(isFromDirection)` to stack +2. Stack creates `ChooseTokenComponent` with appropriate bridge (FROM or TO) +3. `ChooseTokenBridge` communicates selection result via Channel +4. `onTokenSelect(result)` assigns selected token to FROM or TO based on `isFromDirection` + +**Swap execution flow:** +1. `onSwapClick()` — validates state, checks approval, initiates transaction +2. If approval needed → `approvalSlotNavigation.activate(Unit)` +3. On approval done → reloads quotes +4. On swap success → `swapRouter.openScreen(SwapRoute.Success)` + +### StateBuilder (impl) +Pure transformation class. Takes `UiActions` + providers, builds `SwapStateHolder` from `SwapProcessDataState`. + +Key methods: `createInitialLoadingState`, `createQuotesLoadedState`, `createSuccessState`, `loadingPermissionState`, `updateSwapAmount`, `addNotification`, `dismissBottomSheet`. + +### SwapRouter (impl) +Wraps `AppRouter` + `StackNavigation`. Handles `openScreen(SwapRoute)` to push/replace stack entries and `back()` with special logic: SelectToken pops local stack, Success exits to screen before SwapCrypto in app stack, Main pops AppRouter. `openTokenDetails()` navigates to `AppRoute.CurrencyDetails`. + +## Token Selection Subfeature (impl) + +Self-contained within `choosetoken/` package: +- `ChooseTokenComponent` — API with `Params(bridge, settings, analyticsPayload)` +- `ChooseTokenBridge` — Channel-based communication: `onCurrencyChosen`, `onClose`, `onTokenSelected` (legacy), `onNewTokenAdded` (legacy). Has `settingsStateFlow` for dynamic settings. +- `ChooseTokenComponent.Settings` — `SwapFrom` (no market block) vs `SwapTo` (with market block) +- `ChooseTokenResult` — Contains `CryptoCurrencyStatus`, `AccountStatus`, `UserWallet` +- `DefaultChooseTokenComponent` — Has its own `ChooseTokenModel` and optional `AddToPortfolioComponent` bottom sheet slot + +## Domain Layer + +### SwapInteractor +Central domain interface. Methods: +- `getPair(from, to, filterProviderTypes)` → `Either>` +- `findBestQuote(from, to, providers, amount, ...)` → `Map` +- `onSwap(from, to, provider, swapData, amount, fee, ...)` → `SwapTransactionState` +- `loadFeeForSwapTransaction(...)` → `Either` +- `getInitialCurrencyToSwap(accountStatusList, fromUserWallet, isReverse)` → `AccountCryptoCurrencyStatus?` +- `getTokenBalance(token)` → `SwapAmount` + +### Key Domain Models +- `SwapPairLeast` — from/to token info + providers list +- `SwapProvider` — providerId, name, type (DEX/CEX/DEX_BRIDGE), rates, slippage, TOS links +- `SwapState` — sealed: `QuotesLoadedState`, `SwapError`, `EmptyAmountState` +- `SwapCurrencyStatus` — wraps `CryptoCurrencyStatus` + `UserWallet` + `Account` +- `SwapAmount` — value + decimals pair +- `SwapDataModel` — quote result with transaction data + +## DI Modules + +| Module | Scope | Bindings | +|--------|-------|----------| +| `SwapFeatureModule` | Singleton | `SwapComponent.Factory`, `SwapFeatureToggles` | +| `SwapModelModule` | ModelComponent | `SwapModel` into model map | +| `SwapEntryModule` | Singleton + Model | `SwapEntryComponent.Factory`, `SwapEntryModel` | +| `ChooseTokenModule` | Singleton + Model | `ChooseTokenComponent.Factory`, `ChooseTokenBridge.Factory`, `ChooseTokenModel` | +| `SwapSingletonModule` | Singleton | `AmountFormatter` | + +## UI Layer + +- `SwapScreen` — main swap composable (send card, receive card, swap button, provider, notifications, fee) +- `SwapSuccessScreen` — post-swap success with transaction details +- `SwapScreenContent` — layout with `ConstraintLayout` for card positioning +- `TransactionCard` / `TransactionCardEmpty` — token cards with amount input +- Token cards pass `TokenSelectionDirection.FROM` / `.TO` to `onSelectTokenClick` + +## Navigation Summary + +``` +AppRouter (global) + └─ AppRoute.Swap → DefaultSwapComponent + ├─ childStack(SwapRoute) + │ ├─ SwapRoute.Main → SwapMainChild (renders SwapScreen) + │ ├─ SwapRoute.Success → SwapSuccessChild (renders SwapSuccessScreen) + │ └─ SwapRoute.SelectToken → ChooseTokenComponent (FROM or TO bridge) + ├─ SlotNavigation (Approval) + │ └─ GiveApprovalComponent (bottom sheet) + └─ SlotNavigation + └─ SwapFeeSelectorBlockComponent (inline fee block) +``` + +## Build Commands + +```bash +./gradlew :features:swap:impl:compileDebugKotlin +./gradlew :features:swap:api:compileDebugKotlin +./gradlew :features:swap:domain:compileDebugKotlin +./gradlew :features:swap:impl:detekt +``` \ No newline at end of file diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index 2d0f54f7b2..6b8f708a35 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -9,11 +9,10 @@ import java.math.BigDecimal interface SwapComponent : ComposableContentComponent { data class Params( - val currencyFrom: CryptoCurrency, - val currencyTo: CryptoCurrency? = null, val userWalletId: UserWalletId, - val isInitialReverseOrder: Boolean = false, + val cryptoCurrency: CryptoCurrency? = null, val screenSource: String, + val currencyPosition: CurrencyPosition = CurrencyPosition.ANY, val tangemPayInput: TangemPayInput? = null, ) { data class TangemPayInput( @@ -22,6 +21,16 @@ interface SwapComponent : ComposableContentComponent { val depositAddress: String, val isWithdrawal: Boolean, ) + + /** Preferred position of the pre-selected currency on the swap screen. */ + enum class CurrencyPosition { + /** Force-place as the FROM (send) currency. */ + FROM, + /** Force-place as the TO (receive) currency. */ + TO, + /** Auto-determine position based on availability and balance. */ + ANY, + } } interface Factory : ComponentFactory diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index e27cb8705f..21cc38dfd0 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -23,6 +23,7 @@ import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.feature.swap.converters.* import com.tangem.feature.swap.domain.api.SwapRepository @@ -226,7 +227,8 @@ internal class DefaultSwapRepository( } override suspend fun getExchangeStatus( - userWallet: UserWallet, + userWallet: UserWallet?, + userWalletId: UserWalletId, txId: String, ): Either { return withContext(coroutineDispatcher.io) { @@ -236,7 +238,7 @@ internal class DefaultSwapRepository( exchangeStatusConverter.convert( tangemExpressApi .getExchangeStatus( - userWalletId = userWallet.walletId.stringValue, + userWalletId = userWalletId.stringValue, refCode = ExpressUtils.getRefCode( userWallet = userWallet, appPreferencesStore = appPreferencesStore, diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt index 1d102e458e..fe9a0e7d93 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapTransactionRepository.kt @@ -41,7 +41,8 @@ internal class DefaultSwapTransactionRepository( private val userTokensResponseFactory = UserTokensResponseFactory() override suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -62,7 +63,8 @@ internal class DefaultSwapTransactionRepository( val tokenTransactions = savedTransactions ?.firstOrNull { savedTx -> savedTx.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) @@ -76,7 +78,8 @@ internal class DefaultSwapTransactionRepository( mutablePreferences.setObject( key = PreferencesKeys.SWAP_TRANSACTIONS_KEY, value = savedTransactions?.updateList( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -84,7 +87,8 @@ internal class DefaultSwapTransactionRepository( transactions = tokenTransactions, ) ?: listOf( converter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -111,13 +115,13 @@ internal class DefaultSwapTransactionRepository( ) { savedTransactions, txStatuses, accountList -> val currencyToTxs = savedTransactions?.filter { savedTx -> - val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue + val isUserWallet = savedTx.toUserWalletId == userWallet.walletId.stringValue val isToCurrency = savedTx.toCryptoCurrencyId == cryptoCurrencyId.value isUserWallet && isToCurrency } val currencyFromTxs = savedTransactions?.filter { savedTx -> - val isUserWallet = savedTx.userWalletId == userWallet.walletId.stringValue + val isUserWallet = savedTx.fromUserWalletId == userWallet.walletId.stringValue val isFromCurrency = savedTx.fromCryptoCurrencyId == cryptoCurrencyId.value isUserWallet && isFromCurrency } @@ -227,18 +231,21 @@ internal class DefaultSwapTransactionRepository( } private fun SavedSwapTransactionListModelInner.checkId( - checkUserWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCurrencyId: CryptoCurrency.ID, toCurrencyId: CryptoCurrency.ID, ): Boolean { - return userWalletId == checkUserWalletId.stringValue && + return this.fromUserWalletId == fromUserWalletId.stringValue && + this.toUserWalletId == toUserWalletId.stringValue && toCryptoCurrencyId == toCurrencyId.value && fromCryptoCurrencyId == fromCurrencyId.value } @Suppress("LongParameterList") private fun List.updateList( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, @@ -247,7 +254,8 @@ internal class DefaultSwapTransactionRepository( ): List { return addOrReplace( item = converter.default( - userWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCryptoCurrency = fromCryptoCurrency, toCryptoCurrency = toCryptoCurrency, fromAccount = fromAccount, @@ -256,7 +264,8 @@ internal class DefaultSwapTransactionRepository( ), predicate = { savedTx -> savedTx.checkId( - checkUserWalletId = userWalletId, + fromUserWalletId = fromUserWalletId, + toUserWalletId = toUserWalletId, fromCurrencyId = fromCryptoCurrency.id, toCurrencyId = toCryptoCurrency.id, ) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt index dba1d47550..10b480b9a1 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/converters/SavedSwapTransactionListConverter.kt @@ -28,7 +28,8 @@ internal class SavedSwapTransactionListConverter( private val userTokensResponseFactory = UserTokensResponseFactory() override fun convert(value: SavedSwapTransactionListModel) = SavedSwapTransactionListModelInner( - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromTokensResponse = userTokensResponseFactory.createResponseToken( @@ -98,7 +99,8 @@ internal class SavedSwapTransactionListConverter( val statusWithRefundCurrency = status?.copy(refundCurrency = refundCurrency) tx.copy(status = statusWithRefundCurrency) }, - userWalletId = value.userWalletId, + fromUserWalletId = value.fromUserWalletId, + toUserWalletId = value.toUserWalletId, fromCryptoCurrencyId = value.fromCryptoCurrencyId, toCryptoCurrencyId = value.toCryptoCurrencyId, fromCryptoCurrency = fromCryptoCurrency, @@ -117,14 +119,16 @@ internal class SavedSwapTransactionListConverter( @Suppress("LongParameterList") fun default( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, toAccount: Account?, tokenTransactions: List, ) = SavedSwapTransactionListModelInner( - userWalletId = userWalletId.stringValue, + fromUserWalletId = fromUserWalletId.stringValue, + toUserWalletId = toUserWalletId.stringValue, fromCryptoCurrencyId = fromCryptoCurrency.id.value, toCryptoCurrencyId = toCryptoCurrency.id.value, fromTokensResponse = userTokensResponseFactory.createResponseToken( diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index b76d4c6e4c..5a567049e0 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -20,6 +20,8 @@ dependencies { kapt(deps.hilt.kapt) /** Domain */ + implementation(projects.domain.swap.models) + implementation(projects.domain.swap) implementation(projects.domain.appCurrency) implementation(projects.domain.appCurrency.models) implementation(projects.domain.card) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt deleted file mode 100644 index e8d4a35b72..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/DefaultInitialToCurrencyResolver.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.feature.swap.domain - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency -import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress -import com.tangem.feature.swap.domain.models.ui.getGroupWithReverse -import com.tangem.utils.extensions.orZero - -internal class DefaultInitialToCurrencyResolver( - private val swapTransactionRepository: SwapTransactionRepository, -) : InitialToCurrencyResolver { - - override suspend fun tryGetFromCache( - userWallet: UserWallet, - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? { - val id = swapTransactionRepository.getLastSwappedCryptoCurrencyId(userWallet.walletId) ?: return null - - return if (id != initialCryptoCurrency.id.value) { - val group = state.getGroupWithReverse(isReverseFromTo) - - group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> - currencyList.find { it.isAvailable && it.cryptoCurrencyStatus.currency.id.value == id } - } - } else { - null - } - } - - override fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? { - val group = state.getGroupWithReverse(isReverseFromTo) - return group.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> - currencyList.maxByOrNull { swapAccountCurrency -> - swapAccountCurrency.cryptoCurrencyStatus.value.fiatAmount - .takeIf { swapAccountCurrency.isAvailable } - .orZero() - } - } - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt deleted file mode 100644 index da6436ee57..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/InitialToCurrencyResolver.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.feature.swap.domain - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.swap.domain.models.ui.AccountSwapCurrency -import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress - -interface InitialToCurrencyResolver { - - suspend fun tryGetFromCache( - userWallet: UserWallet, - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? - - fun tryGetWithMaxAmount(state: TokensDataStateExpress, isReverseFromTo: Boolean): AccountSwapCurrency? -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt index ae3c82c978..98d5c4d9e1 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractor.kt @@ -2,87 +2,58 @@ package com.tangem.feature.swap.domain import arrow.core.Either import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressOperationType -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.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.PermissionOptions -import com.tangem.feature.swap.domain.models.domain.SwapDataModel -import com.tangem.feature.swap.domain.models.domain.SwapProvider -import com.tangem.feature.swap.domain.models.ui.* +import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.SwapTransactionState +import com.tangem.feature.swap.domain.models.ui.TxFee import java.math.BigDecimal interface SwapInteractor { - suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress + suspend fun getPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + ): Either> - /** - * Gives permission to swap, this starts scan card process - * - * @param networkId network in which selected token - * @param permissionOptions data to give permissions - */ - @Throws(IllegalStateException::class) - suspend fun givePermissionToSwap(networkId: String, permissionOptions: PermissionOptions): SwapTransactionState + suspend fun findProvidersForPairWithCheck( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List + + fun findProvidersForPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List - /** - * Find best quote for given tokens to swap - * under the hood calls different methods to receive data, depends on permission for given token - * - * @param fromToken token from which want to swap - * @param fromAccount account from which swap will be made - * @param toToken token that receive after swap - * @param toAccount account to which receive token after swap - * @param providers list of providers to find quote - * @param amountToSwap amount you want to swap - * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - * @param txFeeSealedState selected fee to swap - * @return - */ @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun findBestQuote( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, txFeeSealedState: TxFeeSealedState, ): Map - /** - * Starts swap transaction, perform sign transaction - * - * @param swapProvider swap provider to use - * @param swapData tx data to swap, contains data to sign - * @param currencyToSend crypto currency to send - * @param currencyToGet crypto currency to get - * @param fromAccount account from which swap will be made - * @param toAccount account to which receive token - * @param amountToSwap amount to swap - * @param includeFeeInAmount flag to include fee in amount - * @param fee for tx (can be null only for tangem pay withdrawal) - * @param expressOperationType type of express operation - - * @return [SwapTransactionState] - */ @Suppress("LongParameterList") @Throws(IllegalStateException::class) suspend fun onSwap( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, swapProvider: SwapProvider, swapData: SwapDataModel?, - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee?, @@ -90,14 +61,6 @@ interface SwapInteractor { isTangemPayWithdrawal: Boolean, ): SwapTransactionState - // suspend fun updateQuotesStateWithSelectedFee( - // state: SwapState.QuotesLoadedState, - // selectedFee: FeeType, - // fromToken: CryptoCurrencyStatus, - // amountToSwap: String, - // reduceBalanceBy: BigDecimal, - // ): SwapState.QuotesLoadedState - /** * Returns token in wallet balance * @@ -105,27 +68,12 @@ interface SwapInteractor { */ fun getTokenBalance(token: CryptoCurrencyStatus): SwapAmount - /** - * Returns initial currency to swap as AccountSwapCurrency - * - * @param initialCryptoCurrency initial currency selected to swap - * @param state current tokens data state - * @param isReverseFromTo flag indicating the direction of the swap - */ - suspend fun getInitialCurrencyToSwap( - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? - - suspend fun getNativeToken(network: Network): CryptoCurrency + suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency @Suppress("LongParameterList") suspend fun storeSwapTransaction( - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, swapProvider: SwapProvider, swapDataModel: SwapDataModel, @@ -135,51 +83,19 @@ interface SwapInteractor { averageDuration: Int? = null, ) - /** - * Loads fee for swap transaction - * - * @param fromToken token from which want to swap - * @param fromAccount account from which swap will be made - * @param toToken token that receive after swap - * @param toAccount account to which receive token after swap - * @param amount amount you want to swap - * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - * @param selectedFeeToken selected token to pay fee or null to pay fee with coin - */ - @Suppress("LongParameterList") suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, selectedFeeToken: CryptoCurrencyStatus?, ): Either - /** - * Loads fee for swap transaction - * - * @param fromToken token from which want to swap - * @param fromAccount account from which swap will be made - * @param toToken token that receive after swap - * @param toAccount account to which receive token after swap - * @param amount amount you want to swap - * @param reduceBalanceBy amount to reduce from balance (used for fee calculation) - */ - @Suppress("LongParameterList") suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, ): Either - - interface Factory { - fun create(selectedWalletId: UserWalletId): SwapInteractor - } } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 2a5a8f55ea..6079eb8646 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -19,8 +19,6 @@ import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer -import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -28,19 +26,18 @@ import com.tangem.domain.appcurrency.extenstions.unwrap 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.ExpressOperationType -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.express.models.* 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.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayWithdrawExchangeState 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.SwapTxType +import com.tangem.domain.swap.usecase.GetSwapPairUseCase import com.tangem.domain.tokens.GetAssetRequirementsUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer @@ -59,7 +56,6 @@ import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.walletmanager.WalletManagersFacade -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -69,16 +65,17 @@ import com.tangem.feature.swap.domain.models.ui.* import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.* +import jakarta.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.supervisorScope import java.math.BigDecimal import java.math.BigInteger import java.math.RoundingMode @Suppress("LargeClass", "LongParameterList") -internal class SwapInteractorImpl @AssistedInject constructor( +internal class SwapInteractorImpl @Inject constructor( private val repository: SwapRepository, private val allowPermissionsHandler: AllowPermissionsHandler, private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher, @@ -86,7 +83,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val createTransactionUseCase: CreateTransactionUseCase, private val createTransferTransactionUseCase: CreateTransferTransactionUseCase, private val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase, - private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val isDemoCardUseCase: IsDemoCardUseCase, private val quotesRepository: QuotesRepository, private val multiQuoteStatusFetcher: MultiQuoteStatusFetcher, @@ -95,7 +91,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val appCurrencyRepository: AppCurrencyRepository, private val currenciesRepository: CurrenciesRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, - private val initialToCurrencyResolver: InitialToCurrencyResolver, private val validateTransactionUseCase: ValidateTransactionUseCase, private val estimateFeeUseCase: EstimateFeeUseCase, private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, @@ -104,16 +99,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, private val getFeeUseCase: GetFeeUseCase, private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase, - private val getUserWalletUseCase: GetUserWalletUseCase, private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val getAssetRequirementsUseCase: GetAssetRequirementsUseCase, private val amountFormatter: AmountFormatter, private val rampStateManager: RampStateManager, - private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, private val walletManagersFacade: WalletManagersFacade, private val getAllowanceInfoUseCase: GetAllowanceInfoUseCase, - @Assisted private val userWalletId: UserWalletId, + private val getSwapPairUseCase: GetSwapPairUseCase, ) : SwapInteractor { private val getSelectedAppCurrencyUseCase by lazy(LazyThreadSafetyMode.NONE) { @@ -122,211 +115,79 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val hundredPercent = BigInteger("100") - private val userWallet - get() = getUserWalletUseCase(userWalletId).getOrElse { - error("Failed to get user wallet") - } - - override suspend fun getTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { - return getAccountCurrencyTokensDataState(currency) - } - - private suspend fun getAccountCurrencyTokensDataState(currency: CryptoCurrency): TokensDataStateExpress { - val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( - SingleAccountStatusListProducer.Params(userWalletId), - )?.accountStatuses.orEmpty() - - val walletAccountCurrencyStatusesExceptInitial: Map> = - walletAccountCurrencyStatuses.mapNotNull { accountStatus -> - val filteredCurrencies = when (accountStatus) { - is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies().filterCurrencies(currency) - is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus) + override suspend fun getPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + filterProviderTypes: List, + ): Either> { + return getSwapPairUseCase( + primarySwapCurrencyStatus = fromSwapCurrencyStatus, + secondarySwapCurrencyStatus = toSwapCurrencyStatus, + filterProviderTypes = filterProviderTypes.map { type -> + // Temporary solution until domain layer is migrated + when (type) { + ExchangeProviderType.DEX -> ExpressProviderType.DEX + ExchangeProviderType.CEX -> ExpressProviderType.CEX + ExchangeProviderType.DEX_BRIDGE -> ExpressProviderType.DEX_BRIDGE } - - if (filteredCurrencies.isNotEmpty()) { - accountStatus.account to filteredCurrencies - } else { - null - } - }.toMap() - - if (walletAccountCurrencyStatusesExceptInitial.isEmpty()) { - return TokensDataStateExpress.EMPTY - } - - val pairsLeast = getPairs( - userWallet = userWallet, - initialCurrency = LeastTokenInfo( - contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = currency.network.rawId, - ), - currenciesList = walletAccountCurrencyStatusesExceptInitial.flatMap { accountStatus -> - accountStatus.value.map { it.currency } }, - ) - - return TokensDataStateExpress( - fromGroup = getToCurrenciesGroup( - currency = currency, - leastPairs = pairsLeast.pairs, - cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, - tokenInfoForFilter = { it.to }, - tokenInfoForAvailable = { it.from }, - ), - toGroup = getToCurrenciesGroup( - currency = currency, - leastPairs = pairsLeast.pairs, - cryptoCurrenciesList = walletAccountCurrencyStatusesExceptInitial, - tokenInfoForFilter = { it.from }, - tokenInfoForAvailable = { it.to }, - ), - allProviders = pairsLeast.allProviders, - ) - } - - private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { - val currencyStatus = when (val statusValue = accountStatus.value) { - is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus - else -> return emptyList() - } - - return listOf(currencyStatus) - } - - private fun List.filterCurrencies(currency: CryptoCurrency) = this.filter { status -> - val isDifferentCurrency = status.currency.network.rawId != currency.network.rawId || - status.currency.getContractAddress() != currency.getContractAddress() - - val hasValidStatus = - status.value is CryptoCurrencyStatus.Loaded || status.value is CryptoCurrencyStatus.NoAccount - val isNotCustomToken = !status.currency.isCustom - - hasValidStatus && isDifferentCurrency && isNotCustomToken - } - - private suspend fun getToCurrenciesGroup( - currency: CryptoCurrency, - leastPairs: List, - cryptoCurrenciesList: Map>, - tokenInfoForFilter: (SwapPairLeast) -> LeastTokenInfo, - tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, - ): CurrenciesGroup { - val filteredPairs = leastPairs.filter { pair -> - tokenInfoForFilter(pair).contractAddress == currency.getContractAddress() && - tokenInfoForFilter(pair).network == currency.network.rawId - } - - val accountCurrencyList = cryptoCurrenciesList.map { (accountEntry, currencyStatusList) -> - AccountSwapAvailability( - account = accountEntry, - currencyList = currencyStatusList.map { currencyStatus -> - val providers = findProvidersForPair( - cryptoCurrencyStatuses = currencyStatus, - swapPairsLeastList = filteredPairs, - tokenInfoForAvailable = tokenInfoForAvailable, - ) - val isUnavailable = providers.isNullOrEmpty() - AccountSwapCurrency( - isAvailable = !isUnavailable, - account = accountEntry, - cryptoCurrencyStatus = currencyStatus, - providers = providers.orEmpty(), - ) - }, - ) - } - - return CurrenciesGroup( - available = emptyList(), - unavailable = emptyList(), - accountCurrencyList = accountCurrencyList, - isAfterSearch = false, - ) - } - - private suspend fun findProvidersForPair( - cryptoCurrencyStatuses: CryptoCurrencyStatus, - swapPairsLeastList: List, - tokenInfoForAvailable: (SwapPairLeast) -> LeastTokenInfo, - ): List? { - val requirements = getAssetRequirementsUseCase.invoke(userWalletId, cryptoCurrencyStatuses.currency).getOrNull() - val isAvailableForSwap = rampStateManager.checkAssetRequirements(requirements) - - return swapPairsLeastList.firstNotNullOfOrNull { pair -> - val listTokenInfo = tokenInfoForAvailable(pair) - if (cryptoCurrencyStatuses.currency.network.rawId == listTokenInfo.network && - cryptoCurrencyStatuses.currency.getContractAddress() == listTokenInfo.contractAddress && - isAvailableForSwap - ) { - pair.providers - } else { - null + swapTxType = SwapTxType.Swap, + ).map { pairs -> + pairs.map { pair -> + SwapPairLeast( + from = LeastTokenInfo( + contractAddress = pair.from.currency.getContractAddress(), + network = pair.from.currency.network.rawId, + ), + to = LeastTokenInfo( + contractAddress = pair.to.currency.getContractAddress(), + network = pair.to.currency.network.rawId, + ), + providers = pair.providers.map { provider -> + provider.toSwapProvider() + }, + ) } } } - private fun CryptoCurrency.getContractAddress(): String { - return when (this) { - is CryptoCurrency.Token -> this.contractAddress - is CryptoCurrency.Coin -> "0" - } + override fun findProvidersForPair( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List { + return pairs.firstOrNull { pair -> + pair.from.network == fromSwapCurrencyStatus.currency.network.rawId && + pair.from.contractAddress == fromSwapCurrencyStatus.currency.getContractAddress() && + pair.to.network == toSwapCurrencyStatus.currency.network.rawId + pair.to.contractAddress == toSwapCurrencyStatus.currency.getContractAddress() + }?.providers.orEmpty() } - private suspend fun getPairs( - userWallet: UserWallet, - initialCurrency: LeastTokenInfo, - currenciesList: List, - ): PairsWithProviders { - return repository.getPairs( - userWallet = userWallet, - initialCurrency = initialCurrency, - currencyList = currenciesList, + override suspend fun findProvidersForPairWithCheck( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + ): List { + val requirements = getAssetRequirementsUseCase.invoke( + fromSwapCurrencyStatus.userWalletId, + fromSwapCurrencyStatus.currency, + ).getOrNull() + + if (!rampStateManager.checkAssetRequirements(requirements)) { + return emptyList() + } + + return findProvidersForPair( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + pairs = pairs, ) } - override suspend fun givePermissionToSwap( - networkId: String, - permissionOptions: PermissionOptions, - ): SwapTransactionState { - val amount = permissionOptions.approveData.fromTokenAmount.takeIf { - permissionOptions.approveType == SwapApproveType.LIMITED - } - - val approveTransaction = createApprovalTransactionUseCase( - fee = permissionOptions.txFee.fee, - userWalletId = userWalletId, - cryptoCurrencyStatus = permissionOptions.fromTokenStatus, - amount = amount?.value, - contractAddress = permissionOptions.forTokenContractAddress, - spenderAddress = permissionOptions.spenderAddress, - ).getOrElse { error -> - TangemLogger.e("Failed to create approveTransaction", error) - return SwapTransactionState.Error.UnknownError - } - - val result = sendTransactionUseCase( - txData = approveTransaction, - userWallet = userWallet, - network = permissionOptions.fromTokenStatus.currency.network, - ) - return result.fold( - ifRight = { hash -> - allowPermissionsHandler.addAddressToInProgress(permissionOptions.forTokenContractAddress) - SwapTransactionState.TxSent( - txHash = hash, - timestamp = System.currentTimeMillis(), - ) - }, - ifLeft = { SwapTransactionState.Error.TransactionError(it) }, - ) - } - - @Suppress("LongMethod") override suspend fun findBestQuote( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, providers: List, amountToSwap: String, reduceBalanceBy: BigDecimal, @@ -335,10 +196,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( TangemLogger.i( """ Find the best quote - |- fromToken: $fromToken - |- fromAccount: $fromAccount - |- toToken: $toToken - |- toAccount: $toAccount + |- fromSwapCurrencyStatus: + |---- walletId: ${fromSwapCurrencyStatus.userWalletId} + |---- accountId: ${fromSwapCurrencyStatus.account.accountId} + |---- currencyId: ${fromSwapCurrencyStatus.currency.id} + |- toSwapCurrencyStatus: $toSwapCurrencyStatus + |---- walletId: ${toSwapCurrencyStatus.userWalletId} + |---- accountId: ${toSwapCurrencyStatus.account.accountId} + |---- currencyId: ${toSwapCurrencyStatus.currency.id} |- providers: $providers |- amountToSwap: $amountToSwap |- selectedFee: $txFeeSealedState @@ -349,74 +214,46 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (amountDecimal == null || amountDecimal.signum() == 0) { return providers.associateWith { createEmptyAmountState() } } - val amount = SwapAmount(amountDecimal, fromToken.currency.decimals) - val isBalanceWithoutFeeEnough = when (fromAccount) { - is Account.Payment -> true - else -> isBalanceEnough(fromToken, amount, null) - } - val networkId = fromToken.currency.network.rawId - + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) + val isBalanceWithoutFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, null) return supervisorScope { providers.map { provider -> async { - try { - when (provider.type) { - ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - if (isSolana(networkId)) { - manageDexSolana( - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - provider = provider, - txFeeSealedState = txFeeSealedState, - amount = amount, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - expressOperationType = ExpressOperationType.SWAP, - ) - } else { - manageDex( - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - provider = provider, - txFeeSealedState = txFeeSealedState, - amount = amount, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, - expressOperationType = ExpressOperationType.SWAP, - ) - } - } - ExchangeProviderType.CEX -> { - manageCex( - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + when (provider.type) { + ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { + if (isSolana(fromSwapCurrencyStatus.currency.network.rawId)) { + manageDexSolana( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, - amount = amount, - reduceBalanceBy = reduceBalanceBy, - isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, txFeeSealedState = txFeeSealedState, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + expressOperationType = ExpressOperationType.SWAP, + ) + } else { + manageDex( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + provider = provider, + txFeeSealedState = txFeeSealedState, + amount = amount, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + expressOperationType = ExpressOperationType.SWAP, ) } } - } catch (e: Throwable) { - if (e is CancellationException) { - throw e + ExchangeProviderType.CEX -> { + manageCex( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + provider = provider, + amount = amount, + reduceBalanceBy = reduceBalanceBy, + isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, + txFeeSealedState = txFeeSealedState, + ) } - TangemLogger.e("Failed to find quote for provider: ${provider.providerId}", e) - provider to createSwapErrorWith( - fromToken = fromToken, - fromAccount = fromAccount, - amount = amount, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - expressDataError = ExpressDataError.UnknownError, - ) } } }.awaitAll().toMap() @@ -425,46 +262,42 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod") private suspend fun manageDex( - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, isBalanceWithoutFeeEnough: Boolean, expressOperationType: ExpressOperationType, ): Pair { - if (fromToken.value.yieldSupplyStatus?.isActive == true) { + if (fromSwapCurrencyStatus.status.value.yieldSupplyStatus?.isActive == true) { return provider to produceDexSwapDataError( error = ExpressDataError.DexActiveSupplyError, - fromToken = fromToken, - fromAccount = fromAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, ) } val maybeQuotes = repository.findBestQuote( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.rawId, - toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.rawId, + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, ) - val fromTokenAddress = getTokenAddress(fromToken.currency) + val fromTokenAddress = getTokenAddress(fromSwapCurrencyStatus.currency) val isAllowedToSpend = maybeQuotes.fold( ifRight = { quotes -> quotes.allowanceContract?.let { allowanceContract -> getAllowanceInfoUseCase( - userWalletId = userWalletId, - cryptoCurrency = fromToken.currency, + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrency = fromSwapCurrencyStatus.currency, spenderAddress = allowanceContract, requiredAmount = amount.value, ).getOrNull() is AllowanceInfo.Enough @@ -475,16 +308,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (isAllowedToSpend && allowPermissionsHandler.isAddressAllowanceInProgress(fromTokenAddress)) { allowPermissionsHandler.removeAddressFromProgress(fromTokenAddress) - cryptoCurrencyBalanceFetcher(userWalletId = userWalletId, currency = fromToken.currency) + cryptoCurrencyBalanceFetcher( + userWalletId = fromSwapCurrencyStatus.userWalletId, + currency = fromSwapCurrencyStatus.currency, + ) } return if (isAllowedToSpend && isBalanceWithoutFeeEnough) { provider to loadDexSwapData( provider = provider, - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, @@ -494,11 +327,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, quoteDataModel = maybeQuotes, amount = amount, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, txFeeSealedState = txFeeSealedState, @@ -508,11 +338,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageDexSolana( - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, @@ -520,14 +347,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( expressOperationType: ExpressOperationType, ): Pair { val maybeQuotes = repository.findBestQuote( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.rawId, - toContractAddress = toToken.currency.getContractAddress(), - toNetwork = toToken.currency.network.rawId, + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, ) @@ -535,11 +362,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( return if (isBalanceWithoutFeeEnough && maybeQuotes.isRight()) { provider to loadDexSwapData( provider = provider, - networkId = networkId, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, expressOperationType = expressOperationType, @@ -549,11 +373,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, quoteDataModel = maybeQuotes, amount = amount, - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, isBalanceWithoutFeeEnough = false, txFeeSealedState = txFeeSealedState, @@ -563,11 +384,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageCex( - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, amount: SwapAmount, reduceBalanceBy: BigDecimal, @@ -575,13 +393,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( txFeeSealedState: TxFeeSealedState, ): Pair { return provider to loadCexQuoteData( - networkId = networkId, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromTokenStatus = fromToken, - fromAccount = fromAccount, - toTokenStatus = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = true, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, provider = provider, @@ -590,7 +405,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageWarnings( - fromTokenStatus: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFeeSealed: TxFeeSealedState?, includeFeeInAmount: IncludeFeeInAmount, @@ -614,7 +429,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } ?: BigDecimal.ZERO val balanceAfterTransaction = getCoinBalanceAfterTransaction( - fromTokenStatus = fromTokenStatus, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, includeFeeInAmount = includeFeeInAmount, fee = fee, @@ -625,12 +440,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( amount } val feePaidCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = fromTokenStatus, + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).getOrNull() val currencyCheck = getCurrencyCheckUseCase( - userWalletId = userWalletId, - currencyStatus = fromTokenStatus, + userWalletId = fromSwapCurrencyStatus.userWalletId, + currencyStatus = fromSwapCurrencyStatus.status, feeCurrencyStatus = feePaidCurrencyStatus, amount = amountToRequest.value, fee = fee, @@ -641,14 +456,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun getCoinBalanceAfterTransaction( - fromTokenStatus: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, fee: BigDecimal, ): BigDecimal? { - return when (fromTokenStatus.currency) { + return when (fromSwapCurrencyStatus.currency) { is CryptoCurrency.Coin -> { - val statusValue = fromTokenStatus.value as? CryptoCurrencyStatus.Loaded + val statusValue = fromSwapCurrencyStatus.status.value as? CryptoCurrencyStatus.Loaded when (includeFeeInAmount) { is IncludeFeeInAmount.Included -> { statusValue?.let { it.amount - includeFeeInAmount.amountSubtractFee.value - fee } @@ -660,15 +475,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } is CryptoCurrency.Token -> { - val feePaidCurrency = getFeePaidCurrency( - currency = fromTokenStatus.currency, - ) + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) when (feePaidCurrency) { FeePaidCurrency.Coin -> { val nativeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, - networkId = fromTokenStatus.currency.network.rawId, - derivationPath = fromTokenStatus.currency.network.derivationPath.value, + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromSwapCurrencyStatus.currency.network.rawId, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) nativeBalance - fee @@ -680,12 +493,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun manageTransactionValidationWarnings( - fromToken: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFeeSealedState: TxFeeSealedState, - userWalletId: UserWalletId, ): Throwable? { - val currency = fromToken.currency + val currency = fromSwapCurrencyStatus.currency val blockchain = currency.network.toBlockchain() // Stellar validation removed because swap uses destination = "0" and throws an error if (blockchain == Blockchain.Stellar) { @@ -710,11 +522,11 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) val result = validateTransactionUseCase( - amount = amount.value.convertToSdkAmount(fromToken), + amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), fee = fee, memo = null, - destination = getTokenAddress(fromToken.currency), - userWalletId = userWalletId, + destination = getTokenAddress(fromSwapCurrencyStatus.currency), + userWalletId = fromSwapCurrencyStatus.userWalletId, network = currency.network, ).leftOrNull() @@ -723,12 +535,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("NullableToStringCall") override suspend fun onSwap( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, swapProvider: SwapProvider, swapData: SwapDataModel?, - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amountToSwap: String, includeFeeInAmount: IncludeFeeInAmount, fee: TxFee?, @@ -740,15 +550,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( Swap |- swapProvider: $swapProvider |- swapData: $swapData - |- currencyToSend: $currencyToSend - |- currencyToGet: $currencyToGet + |- fromSwapCurrencyStatus: + |---- walletId: ${fromSwapCurrencyStatus.userWalletId} + |---- accountId: ${fromSwapCurrencyStatus.account.accountId} + |---- currencyId: ${fromSwapCurrencyStatus.currency.id} + |- toSwapCurrencyStatus: $toSwapCurrencyStatus + |---- walletId: ${toSwapCurrencyStatus.userWalletId} + |---- accountId: ${toSwapCurrencyStatus.account.accountId} + |---- currencyId: ${toSwapCurrencyStatus.currency.id} |- amountToSwap: $amountToSwap |- includeFeeInAmount: $includeFeeInAmount |- fee: $fee """.trimIndent(), ) - val userWallet = userWallet + val userWallet = fromSwapCurrencyStatus.userWallet if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { return SwapTransactionState.DemoMode } @@ -756,17 +572,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( return when (swapProvider.type) { ExchangeProviderType.CEX -> { val amountDecimal = toBigDecimalOrNull(amountToSwap) - val amount = SwapAmount(requireNotNull(amountDecimal), currencyToSend.currency.decimals) + val amount = SwapAmount(requireNotNull(amountDecimal), fromSwapCurrencyStatus.currency.decimals) val amountToSwapWithFee = if (includeFeeInAmount is IncludeFeeInAmount.Included) { includeFeeInAmount.amountSubtractFee } else { amount } onSwapCex( - currencyToSend = currencyToSend, - currencyToGet = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amountToSwapWithFee, txFee = fee, swapProvider = swapProvider, @@ -775,15 +589,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) } ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { - val networkId = currencyToSend.currency.network.rawId + val networkId = fromSwapCurrencyStatus.currency.network.rawId if (isSolana(networkId)) { onSwapSolanaDex( provider = swapProvider, swapData = requireNotNull(swapData), - currencyToSendStatus = currencyToSend, - currencyToGetStatus = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amountToSwap = amountToSwap, ) } else { @@ -791,10 +603,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( onSwapDex( provider = swapProvider, swapData = requireNotNull(swapData), - currencyToSendStatus = currencyToSend, - currencyToGetStatus = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, txFee = fee, amountToSwap = amountToSwap, ) @@ -804,41 +614,41 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun onSwapDex( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, swapData: SwapDataModel, - currencyToSendStatus: CryptoCurrencyStatus, - currencyToGetStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amountToSwap: String, txFee: TxFee, ): SwapTransactionState { val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txValue = requireNotNull(swapData.transaction.txValue) { "txValue is null" } - val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) val dexTransaction = swapData.transaction as ExpressTransactionModel.DEX val dataToSign = dexTransaction.txData - val amountToSend = createNativeAmountForDex(txValue, currencyToSendStatus.currency.network) + val amountToSend = createNativeAmountForDex(txValue, fromSwapCurrencyStatus.currency.network) val txData = createTransactionUseCase( amount = amountToSend, fee = txFee.fee, memo = null, destination = swapData.transaction.txTo, - userWalletId = userWalletId, - network = currencyToSendStatus.currency.network, - txExtras = createDexTxExtras(dataToSign, currencyToSendStatus.currency.network, txFee.fee.getGasLimit()), + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = toSwapCurrencyStatus.currency.network, + txExtras = createDexTxExtras( + dataToSign, + fromSwapCurrencyStatus.currency.network, + txFee.fee.getGasLimit(), + ), ).getOrElse { error -> TangemLogger.e("Failed to create swap dex tx data", error) return SwapTransactionState.Error.UnknownError } return handleSwapResult( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, swapData = swapData, - currencyToSendStatus = currencyToSendStatus, - currencyToGetStatus = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, amount = amount, txData = txData, payInAddress = getPayoutAddress(txData), @@ -848,26 +658,22 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun onSwapSolanaDex( provider: SwapProvider, swapData: SwapDataModel, - currencyToSendStatus: CryptoCurrencyStatus, - currencyToGetStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amountToSwap: String, ): SwapTransactionState { val dexTransaction = swapData.transaction as? ExpressTransactionModel.DEX val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" } val txDataBase64 = requireNotNull(dexTransaction?.txData) { "txData is null" } - val amount = SwapAmount(amountDecimal, currencyToSendStatus.currency.decimals) + val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals) val compiledTransaction = TransactionData.Compiled( value = TransactionData.Compiled.Data.Bytes(Base64.decode(txDataBase64, Base64.NO_WRAP)), ) return handleSwapResult( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = provider, swapData = swapData, - currencyToSendStatus = currencyToSendStatus, - currencyToGetStatus = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, amount = amount, txData = compiledTransaction, payInAddress = swapData.transaction.txTo, @@ -875,29 +681,27 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun handleSwapResult( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, provider: SwapProvider, swapData: SwapDataModel, - currencyToSendStatus: CryptoCurrencyStatus, - currencyToGetStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, amount: SwapAmount, txData: TransactionData, payInAddress: String, ): SwapTransactionState { val result = sendTransactionUseCase( txData = txData, - userWallet = userWallet, - network = currencyToSendStatus.currency.network, + userWallet = fromSwapCurrencyStatus.userWallet, + network = fromSwapCurrencyStatus.currency.network, ) return result.fold( ifRight = { txHash -> - val networkAddress = currencyToSendStatus.value.networkAddress + val networkAddress = fromSwapCurrencyStatus.status.value.networkAddress val fromAddress = networkAddress?.defaultAddress?.value.orEmpty() repository.exchangeSent( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, txId = swapData.transaction.txId, - fromNetwork = currencyToSendStatus.currency.network.rawId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, fromAddress = fromAddress, payInAddress = payInAddress, txHash = txHash, @@ -905,25 +709,23 @@ internal class SwapInteractorImpl @AssistedInject constructor( ) val timestamp = System.currentTimeMillis() storeSwapTransaction( - currencyToSend = currencyToSendStatus, - currencyToGet = currencyToGetStatus, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, swapProvider = provider, swapDataModel = swapData, timestamp = timestamp, ) - storeLastCryptoCurrencyId(currencyToGetStatus.currency) + storeLastCryptoCurrencyId(fromSwapCurrencyStatus) SwapTransactionState.TxSent( fromAmount = amountFormatter.formatSwapAmountToUI( amount, - currencyToSendStatus.currency.symbol, + fromSwapCurrencyStatus.currency.symbol, ), fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( swapData.toTokenAmount, - currencyToGetStatus.currency.symbol, + toSwapCurrencyStatus.currency.symbol, ), toAmountValue = swapData.toTokenAmount.value, txHash = txHash, @@ -944,35 +746,33 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod", "CanBeNonNullable") private suspend fun onSwapCex( - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFee: TxFee?, swapProvider: SwapProvider, expressOperationType: ExpressOperationType, isTangemPayWithdrawal: Boolean, ): SwapTransactionState { - val fromNetworkAddress = currencyToSend.value.networkAddress + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = currencyToGet.value.networkAddress + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val toAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() val exchangeData = repository.getExchangeData( - userWallet = userWallet, - fromContractAddress = currencyToSend.currency.getContractAddress(), - fromNetwork = currencyToSend.currency.network.rawId, - toContractAddress = currencyToGet.currency.getContractAddress(), + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), fromAddress = fromAddress, - toNetwork = currencyToGet.currency.network.rawId, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = currencyToGet.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = swapProvider.providerId, rateType = RateType.FLOAT, expressOperationType = expressOperationType, toAddress = toAddress, - refundAddress = currencyToSend.value.networkAddress?.defaultAddress?.value, + refundAddress = fromNetworkAddress?.defaultAddress?.value, refundExtraId = null, // currently always null, ).getOrElse { error -> return SwapTransactionState.Error.ExpressError(error) } @@ -980,26 +780,23 @@ internal class SwapInteractorImpl @AssistedInject constructor( exchangeData.transaction as? ExpressTransactionModel.CEX ?: return SwapTransactionState.Error.UnknownError if (isTangemPayWithdrawal) { - val networkAddress = currencyToSend.value.networkAddress return SwapTransactionState.TangemPayWithdrawalData( cryptoAmount = amount.value, - cryptoCurrencyId = requireNotNull(currencyToSend.currency.id.rawCurrencyId), + cryptoCurrencyId = requireNotNull(fromSwapCurrencyStatus.currency.id.rawCurrencyId), cexAddress = exchangeDataCex.txTo, fromAmount = amountFormatter.formatSwapAmountToUI( amount, - currencyToSend.currency.symbol, + fromSwapCurrencyStatus.currency.symbol, ), fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( exchangeData.toTokenAmount, - currencyToGet.currency.symbol, + toSwapCurrencyStatus.currency.symbol, ), toAmountValue = exchangeData.toTokenAmount.value, storeData = SwapTransactionState.TangemPayWithdrawalData.StoreTransactionData( - currencyToSend = currencyToSend, - currencyToGet = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, swapProvider = swapProvider, swapDataModel = exchangeData, @@ -1009,26 +806,26 @@ internal class SwapInteractorImpl @AssistedInject constructor( ), exchangeData = TangemPayWithdrawExchangeState( txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.rawId, - fromAddress = networkAddress?.defaultAddress?.value.orEmpty(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + fromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty(), payInAddress = exchangeData.transaction.txTo, payInExtraId = exchangeDataCex.txExtraId, ), ) } - val userWallet = userWallet + val userWallet = fromSwapCurrencyStatus.userWallet if (userWallet is UserWallet.Cold && isDemoCardUseCase(userWallet.scanResponse.card.cardId)) { return SwapTransactionState.Error.UnknownError } val fee = requireNotNull(txFee) val txData = createTransferTransactionUseCase( - amount = amount.value.convertToSdkAmount(currencyToSend), + amount = amount.value.convertToSdkAmount(fromSwapCurrencyStatus.status), fee = fee.fee, memo = exchangeDataCex.txExtraId, destination = exchangeDataCex.txTo, - userWalletId = userWalletId, - network = currencyToSend.currency.network, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromSwapCurrencyStatus.currency.network, ).getOrElse { error -> TangemLogger.e("Failed to create swap CEX tx data", error) return SwapTransactionState.Error.UnknownError @@ -1052,7 +849,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( sendTransactionUseCase( txData = txData, userWallet = userWallet, - network = currencyToSend.currency.network, + network = fromSwapCurrencyStatus.currency.network, ) } } @@ -1060,12 +857,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( sendTransactionUseCase( txData = txData, userWallet = userWallet, - network = currencyToSend.currency.network, + network = fromSwapCurrencyStatus.currency.network, ) } } - val cexNetworkAddress = currencyToSend.value.networkAddress + val cexNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val cexFromAddress = cexNetworkAddress?.defaultAddress?.value.orEmpty() return result.fold( ifLeft = { error -> SwapTransactionState.Error.TransactionError(error) }, @@ -1073,7 +870,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( repository.exchangeSent( userWallet = userWallet, txId = exchangeDataCex.txId, - fromNetwork = currencyToSend.currency.network.rawId, + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, fromAddress = cexFromAddress, payInAddress = getPayoutAddress(txData), txHash = txHash, @@ -1082,10 +879,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( val timestamp = System.currentTimeMillis() val txExternalUrl = exchangeDataCex.externalTxUrl storeSwapTransaction( - currencyToSend = currencyToSend, - currencyToGet = currencyToGet, - fromAccount = fromAccount, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, swapProvider = swapProvider, swapDataModel = exchangeData, @@ -1093,16 +888,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( txExternalUrl = txExternalUrl, txExternalId = exchangeDataCex.externalTxId, ) - storeLastCryptoCurrencyId(currencyToGet.currency) + storeLastCryptoCurrencyId(toSwapCurrencyStatus) SwapTransactionState.TxSent( fromAmount = amountFormatter.formatSwapAmountToUI( amount, - currencyToSend.currency.symbol, + fromSwapCurrencyStatus.currency.symbol, ), fromAmountValue = amount.value, toAmount = amountFormatter.formatSwapAmountToUI( exchangeData.toTokenAmount, - currencyToGet.currency.symbol, + toSwapCurrencyStatus.currency.symbol, ), toAmountValue = exchangeData.toTokenAmount.value, txHash = txHash, @@ -1114,10 +909,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } override suspend fun storeSwapTransaction( - currencyToSend: CryptoCurrencyStatus, - currencyToGet: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, swapProvider: SwapProvider, swapDataModel: SwapDataModel, @@ -1127,11 +920,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( averageDuration: Int?, ) { swapTransactionRepository.storeTransaction( - userWalletId = userWalletId, - fromCryptoCurrency = currencyToSend.currency, - toCryptoCurrency = currencyToGet.currency, - fromAccount = fromAccount, - toAccount = toAccount, + fromUserWalletId = fromSwapCurrencyStatus.userWalletId, + toUserWalletId = toSwapCurrencyStatus.userWalletId, + fromCryptoCurrency = fromSwapCurrencyStatus.currency, + toCryptoCurrency = toSwapCurrencyStatus.currency, + fromAccount = fromSwapCurrencyStatus.account, + toAccount = toSwapCurrencyStatus.account, transaction = SavedSwapTransactionModel( txId = swapDataModel.transaction.txId, provider = swapProvider, @@ -1152,10 +946,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongParameterList") override suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, @@ -1173,16 +964,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( return if (selectedFeeToken != null) { estimateFeeForTokenUseCase( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, feeTokenCurrencyStatus = selectedFeeToken, - sendingTokenCurrencyStatus = fromToken, + sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status, amount = amountDecimal, ) } else { estimateFeeForGaslessTxUseCase( amount = amountDecimal, - userWallet = userWallet, - sendingTokenCurrencyStatus = fromToken, + userWallet = fromSwapCurrencyStatus.userWallet, + sendingTokenCurrencyStatus = fromSwapCurrencyStatus.status, ) } } @@ -1190,10 +981,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } override suspend fun loadFeeForSwapTransaction( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, provider: SwapProvider, @@ -1202,39 +991,36 @@ internal class SwapInteractorImpl @AssistedInject constructor( ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE, -> { - val fromNetworkAddress = fromToken.value.networkAddress + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = toToken.value.networkAddress + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() val amountBigDecimal = toBigDecimalOrNull(amount) if (amountBigDecimal == null || amountBigDecimal.signum() == 0) { raise(GetFeeError.UnknownError) } - val swapAmount = SwapAmount(amountBigDecimal, fromToken.currency.decimals) + val swapAmount = SwapAmount(amountBigDecimal, fromSwapCurrencyStatus.currency.decimals) repository.getExchangeData( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.rawId, - toContractAddress = toToken.currency.getContractAddress(), + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.rawId, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = swapAmount.toStringWithRightOffset(), fromDecimals = swapAmount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, toAddress = dexToAddress, - refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, + refundAddress = fromNetworkAddress?.defaultAddress?.value, expressOperationType = ExpressOperationType.SWAP, ).map { swapData -> - val networkId = fromToken.currency.network.rawId val transaction = swapData.transaction as ExpressTransactionModel.DEX - loadFeeForDex( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, - fromToken = fromToken, ).getOrElse { raise(GetFeeError.UnknownError) } }.mapLeft { GetFeeError.UnknownError @@ -1248,8 +1034,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( estimateFeeUseCase.invoke( amount = amountDecimal, - userWallet = userWallet, - cryptoCurrencyStatus = fromToken, + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).map { it.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) } @@ -1257,10 +1043,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - private suspend fun storeLastCryptoCurrencyId(cryptoCurrency: CryptoCurrency) { + private suspend fun storeLastCryptoCurrencyId(swapCurrencyStatus: SwapCurrencyStatus) { swapTransactionRepository.storeLastSwappedCryptoCurrencyId( - userWalletId = userWalletId, - cryptoCurrencyId = cryptoCurrency.id, + userWalletId = swapCurrencyStatus.userWalletId, + cryptoCurrencyId = swapCurrencyStatus.currency.id, ) } @@ -1268,32 +1054,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( return SwapAmount(token.value.amount ?: BigDecimal.ZERO, token.currency.decimals) } - override suspend fun getInitialCurrencyToSwap( - initialCryptoCurrency: CryptoCurrency, - state: TokensDataStateExpress, - isReverseFromTo: Boolean, - ): AccountSwapCurrency? { - val group = state.getGroupWithReverse(isReverseFromTo) - return initialToCurrencyResolver.tryGetFromCache( - userWallet = userWallet, - initialCryptoCurrency = initialCryptoCurrency, - state = state, - isReverseFromTo = isReverseFromTo, - ) - ?: initialToCurrencyResolver.tryGetWithMaxAmount(state = state, isReverseFromTo = isReverseFromTo) - ?: group.accountCurrencyList.firstNotNullOfOrNull { accountSwapAvailability -> - accountSwapAvailability.currencyList.firstOrNull { accountSwapCurrency -> - accountSwapCurrency.isAvailable - } - } - } - - override suspend fun getNativeToken(network: Network): CryptoCurrency { + override suspend fun getNativeToken(swapCurrencyStatus: SwapCurrencyStatus): CryptoCurrency { + val network = swapCurrencyStatus.currency.network return multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), + params = MultiWalletCryptoCurrenciesProducer.Params(swapCurrencyStatus.userWalletId), ) ?.filterIsInstance() - ?.firstOrNull { it.network.id == network.id && it.network.derivationPath == network.derivationPath } + ?.firstOrNull { nativeCoin -> + nativeCoin.network.id == network.id && + nativeCoin.network.derivationPath == network.derivationPath + } ?: currenciesRepository.createCoinCurrency(network) } @@ -1316,32 +1086,28 @@ internal class SwapInteractorImpl @AssistedInject constructor( */ @Suppress("LongParameterList") private suspend fun loadCexQuoteData( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toTokenStatus: CryptoCurrencyStatus, - toAccount: Account?, provider: SwapProvider, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, txFeeSealedState: TxFeeSealedState, ): SwapState { - val fromToken = fromTokenStatus.currency - val toToken = toTokenStatus.currency + val fromToken = fromSwapCurrencyStatus.currency + val toToken = toSwapCurrencyStatus.currency return coroutineScope { val txFeeSealedStateUpdated = updateTxFeeStateIfNeededForCEX( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, txFeeSealedState = txFeeSealedState, amount = amount, - fromTokenStatus = fromTokenStatus, ) val includeFeeInAmount = getIncludeFeeInAmount( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromTokenStatus, txFeeSealedState = txFeeSealedStateUpdated, ) @@ -1352,7 +1118,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val quotes = repository.findBestQuote( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, fromContractAddress = fromToken.getContractAddress(), fromNetwork = fromToken.network.rawId, toContractAddress = toToken.getContractAddress(), @@ -1368,11 +1134,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, quoteDataModel = quotes, amount = amount, - fromToken = fromTokenStatus, - fromAccount = fromAccount, - toToken = toTokenStatus, - toAccount = toAccount, - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, isAllowedToSpend = isAllowedToSpend, isBalanceWithoutFeeEnough = isBalanceWithoutFeeEnough, txFeeSealedState = txFeeSealedState, @@ -1382,9 +1145,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun updateTxFeeStateIfNeededForCEX( + fromSwapCurrencyStatus: SwapCurrencyStatus, txFeeSealedState: TxFeeSealedState, amount: SwapAmount, - fromTokenStatus: CryptoCurrencyStatus, ): TxFeeSealedState { return when (txFeeSealedState) { is TxFeeSealedState.Component -> txFeeSealedState @@ -1392,10 +1155,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (txFeeSealedState.txFeeState is TxFeeState.Empty) { val txFeeResult = estimateFeeUseCase( amount = amount.value, - userWallet = userWallet, - cryptoCurrencyStatus = fromTokenStatus, + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ) - val txFee = getFeeForCex(txFeeResult, fromTokenStatus) + val txFee = getFeeForCex(txFeeResult, fromSwapCurrencyStatus) TxFeeSealedState.Legacy( txFeeState = txFee, @@ -1413,11 +1176,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider: SwapProvider, quoteDataModel: Either, amount: SwapAmount, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, isAllowedToSpend: Boolean, isBalanceWithoutFeeEnough: Boolean, txFeeSealedState: TxFeeSealedState, @@ -1426,10 +1186,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( return quoteDataModel.fold( ifRight = { quoteModel -> val swapState = updateBalances( - fromTokenStatus = fromToken, - fromAccount = fromAccount, - toTokenStatus = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = amount, toTokenAmount = quoteModel.toTokenAmount, swapData = null, @@ -1437,16 +1195,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( provider = provider, ).copy( currencyCheck = manageWarnings( - fromTokenStatus = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( - fromToken = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, - userWalletId = userWalletId, ), minAdaValue = when (txFeeSealedState) { is TxFeeSealedState.Component -> { @@ -1467,18 +1224,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( when (provider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { val state = updatePermissionState( - networkId = networkId, - fromTokenStatus = fromToken, - fromAccount = fromAccount, - swapAmount = amount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, quotesLoadedState = swapState, isAllowedToSpend = isAllowedToSpend, - spenderAddress = quoteModel.allowanceContract, + swapAmount = amount, + quoteModel = quoteModel, ) if (state !is SwapState.QuotesLoadedState) return state state.copy( preparedSwapConfigState = state.preparedSwapConfigState.copy( - isAllowedToSpend = isAllowedToSpend, isBalanceEnough = isBalanceWithoutFeeEnough, ), ) @@ -1496,18 +1250,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( } val feeState = getFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, fee = fee, spendAmount = amount, - networkId = networkId, - fromTokenStatus = fromToken, ) swapState.copy( permissionState = PermissionDataState.Empty, preparedSwapConfigState = PreparedSwapConfigState( feeState = feeState, - isAllowedToSpend = isAllowedToSpend, isBalanceEnough = isBalanceWithoutFeeEnough, - hasOutgoingTransaction = hasOutgoingTransaction(fromToken), + hasOutgoingTransaction = hasOutgoingTransaction(fromSwapCurrencyStatus.status), includeFeeInAmount = includeFeeInAmount, ), ) @@ -1516,8 +1268,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( }, ifLeft = { error -> createSwapErrorWith( - fromToken = fromToken, - fromAccount = fromAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, includeFeeInAmount = includeFeeInAmount, expressDataError = error, @@ -1527,46 +1278,41 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun createSwapErrorWith( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, ): SwapState.SwapError { - val rates = getQuotes(fromToken.currency.id) + val rates = getQuotes(fromSwapCurrencyStatus.currency.id) val fromTokenSwapInfo = TokenSwapInfo( + swapCurrencyStatus = fromSwapCurrencyStatus, tokenAmount = amount, - amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) - ?: BigDecimal.ZERO, - cryptoCurrencyStatus = fromToken, - account = fromAccount, + amountFiat = rates[fromSwapCurrencyStatus.currency.id]?.fiatRate?.multiply(amount.value) ?: BigDecimal.ZERO, ) return SwapState.SwapError(fromTokenSwapInfo, expressDataError, includeFeeInAmount) } @Suppress("CyclomaticComplexMethod", "NestedBlockDepth", "CastNullableToNonNullableType") private suspend fun getIncludeFeeInAmount( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - fromToken: CryptoCurrencyStatus, txFeeSealedState: TxFeeSealedState, ): IncludeFeeInAmount { return when (txFeeSealedState) { is TxFeeSealedState.Component -> { - if (fromToken.currency.id == txFeeSealedState.txFee.selectedToken?.currency?.id) { + if (fromSwapCurrencyStatus.currency.id == txFeeSealedState.txFee.selectedToken?.currency?.id) { val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO if (txFeeSealedState.txFee.selectedToken.currency is CryptoCurrency.Coin) { getIncludeFeeInAmountForNative( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken.currency, feeValue = fee, ) } else { // we have a token selected for fee payment the same as sending token - val reducedBalance = fromToken.value.amount as BigDecimal - reduceBalanceBy + val reducedBalance = fromSwapCurrencyStatus.status.value.amount as BigDecimal - reduceBalanceBy when { amount.value > reducedBalance -> IncludeFeeInAmount.BalanceNotEnough amount.value + fee <= reducedBalance -> IncludeFeeInAmount.Excluded @@ -1575,7 +1321,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( IncludeFeeInAmount.Included( amountSubtractFee = SwapAmount( value = reducedBalance - fee, - decimals = fromToken.currency.decimals, + decimals = fromSwapCurrencyStatus.currency.decimals, ), ) } else { @@ -1587,10 +1333,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( } else { val fee = txFeeSealedState.txFee.fee.amount.value ?: BigDecimal.ZERO getIncludeFeeInAmountForNative( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken.currency, feeValue = fee, ) } @@ -1604,10 +1349,9 @@ internal class SwapInteractorImpl @AssistedInject constructor( is TxFeeState.SingleFeeState -> txFee.fee.feeIncludeOtherNativeFee } getIncludeFeeInAmountForNative( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, - fromToken = fromToken.currency, feeValue = feeValue, ) } @@ -1615,15 +1359,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun getIncludeFeeInAmountForNative( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, - fromToken: CryptoCurrency, feeValue: BigDecimal, ): IncludeFeeInAmount { - val feePaidCurrency = getFeePaidCurrency( - currency = fromToken, - ) + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) return when (feePaidCurrency) { is FeePaidCurrency.Token -> { @@ -1634,31 +1375,30 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } else -> getIncludeFeeAmountForCoinFee( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, feeValue = feeValue, - fromToken = fromToken, ) } } private suspend fun getIncludeFeeAmountForCoinFee( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, reduceBalanceBy: BigDecimal, feeValue: BigDecimal, - fromToken: CryptoCurrency, ): IncludeFeeInAmount { + val networkId = fromSwapCurrencyStatus.currency.network.rawId val tokenForFeeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, + userWalletId = fromSwapCurrencyStatus.userWalletId, networkId = networkId, - derivationPath = fromToken.network.derivationPath.value, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) val reducedBalance = tokenForFeeBalance - reduceBalanceBy val amountWithFee = amount.value + feeValue return when { - fromToken is CryptoCurrency.Token -> { + fromSwapCurrencyStatus.currency is CryptoCurrency.Token -> { if (feeValue > reducedBalance || reducedBalance.signum() == 0) { IncludeFeeInAmount.BalanceNotEnough } else { @@ -1673,8 +1413,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( } else -> { if (feeValue < amount.value) { - val nativeCoinDecimals = Blockchain.fromNetworkId(networkId)?.decimals() - ?: error("Blockchain not found") + val nativeCoinDecimals = + Blockchain.fromNetworkId(networkId)?.decimals() ?: error("Blockchain not found") IncludeFeeInAmount.Included( amountSubtractFee = SwapAmount( reducedBalance - feeValue, @@ -1688,14 +1428,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - private suspend fun getFormattedFiatFees(fromToken: CryptoCurrency, vararg fees: BigDecimal): List { + private suspend fun getFormattedFiatFees( + fromSwapCurrencyStatus: SwapCurrencyStatus, + vararg fees: BigDecimal, + ): List { val appCurrency = getSelectedAppCurrencyUseCase.unwrap() - val feePaidCurrency = getFeePaidCurrency( - currency = fromToken, - ) + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) val feeCurrencyId: CryptoCurrency.ID = when (feePaidCurrency) { is FeePaidCurrency.Token -> feePaidCurrency.tokenId - else -> getNativeToken(network = fromToken.network).id + else -> getNativeToken(fromSwapCurrencyStatus).id } val rates = getQuotes(feeCurrencyId) return rates[feeCurrencyId]?.let { rate -> @@ -1716,55 +1457,49 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongParameterList", "LongMethod") private suspend fun loadDexSwapData( provider: SwapProvider, - networkId: String, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, txFeeSealedState: TxFeeSealedState, expressOperationType: ExpressOperationType, ): SwapState { - val fromNetworkAddress = fromToken.value.networkAddress + val fromNetworkAddress = fromSwapCurrencyStatus.status.value.networkAddress val dexFromAddress = fromNetworkAddress?.defaultAddress?.value.orEmpty() - val toNetworkAddress = toToken.value.networkAddress + val toNetworkAddress = toSwapCurrencyStatus.status.value.networkAddress val dexToAddress = toNetworkAddress?.defaultAddress?.value.orEmpty() + val networkId = fromSwapCurrencyStatus.currency.network.rawId return repository.getExchangeData( - userWallet = userWallet, - fromContractAddress = fromToken.currency.getContractAddress(), - fromNetwork = fromToken.currency.network.rawId, - toContractAddress = toToken.currency.getContractAddress(), + userWallet = fromSwapCurrencyStatus.userWallet, + fromContractAddress = fromSwapCurrencyStatus.currency.getContractAddress(), + fromNetwork = fromSwapCurrencyStatus.currency.network.rawId, + toContractAddress = toSwapCurrencyStatus.currency.getContractAddress(), fromAddress = dexFromAddress, - toNetwork = toToken.currency.network.rawId, + toNetwork = toSwapCurrencyStatus.currency.network.rawId, fromAmount = amount.toStringWithRightOffset(), fromDecimals = amount.decimals, - toDecimals = toToken.currency.decimals, + toDecimals = toSwapCurrencyStatus.currency.decimals, providerId = provider.providerId, rateType = RateType.FLOAT, toAddress = dexToAddress, - refundAddress = fromToken.value.networkAddress?.defaultAddress?.value, + refundAddress = fromNetworkAddress?.defaultAddress?.value, expressOperationType = expressOperationType, ).fold( ifRight = { swapData -> val transaction = swapData.transaction as ExpressTransactionModel.DEX - val nativeCoinDecimals = Blockchain.fromNetworkId(networkId)?.decimals() - ?: error("Blockchain not found") - val otherNativeFee = transaction.otherNativeFeeWei - ?.movePointLeft(nativeCoinDecimals) - ?: BigDecimal.ZERO + val nativeCoinDecimals = + Blockchain.fromNetworkId(networkId)?.decimals() ?: error("Blockchain not found") + val otherNativeFee = transaction.otherNativeFeeWei?.movePointLeft(nativeCoinDecimals) ?: BigDecimal.ZERO val txFeeState = loadFeeForDex( - networkId = networkId, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, - fromToken = fromToken, ).getOrElse { error -> return@fold produceDexSwapDataError( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, error = error, - fromToken = fromToken, - fromAccount = fromAccount, amount = amount, ) - }.toTxFeeState(fromToken.currency, otherNativeFee) + }.toTxFeeState(fromSwapCurrencyStatus, otherNativeFee) val includeFeeInAmount = IncludeFeeInAmount.Excluded // exclude for dex val feeByPriority = when (txFeeSealedState) { @@ -1776,25 +1511,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } val feeToCheckFunds = feeByPriority + (otherNativeFee ?: BigDecimal.ZERO) - val isBalanceIncludeFeeEnough = isBalanceEnough(fromToken, amount, feeToCheckFunds) + val isBalanceIncludeFeeEnough = isBalanceEnough(fromSwapCurrencyStatus, amount, feeToCheckFunds) val feeState = getFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, fee = feeToCheckFunds, spendAmount = amount, - networkId = networkId, - fromTokenStatus = fromToken, ) val preparedSwapConfigState = PreparedSwapConfigState( - isAllowedToSpend = true, isBalanceEnough = isBalanceIncludeFeeEnough, feeState = feeState, - hasOutgoingTransaction = hasOutgoingTransaction(fromToken), + hasOutgoingTransaction = hasOutgoingTransaction(fromSwapCurrencyStatus.status), includeFeeInAmount = includeFeeInAmount, ) val swapState = updateBalances( - fromTokenStatus = fromToken, - fromAccount = fromAccount, - toTokenStatus = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = amount, toTokenAmount = swapData.toTokenAmount, swapData = swapData, @@ -1804,25 +1535,23 @@ internal class SwapInteractorImpl @AssistedInject constructor( swapState.copy( permissionState = PermissionDataState.Empty, currencyCheck = manageWarnings( - fromTokenStatus = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealed = txFeeSealedState, includeFeeInAmount = includeFeeInAmount, ), validationResult = manageTransactionValidationWarnings( - fromToken = fromToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, amount = amount, txFeeSealedState = txFeeSealedState, - userWalletId = userWalletId, ), preparedSwapConfigState = preparedSwapConfigState, ) }, ifLeft = { error -> produceDexSwapDataError( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, error = error, - fromToken = fromToken, - fromAccount = fromAccount, amount = amount, ) }, @@ -1830,48 +1559,44 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun loadFeeForDex( - networkId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, transaction: ExpressTransactionModel.DEX, - fromToken: CryptoCurrencyStatus, ): Either = either { - if (isSolana(networkId)) { + if (isSolana(fromSwapCurrencyStatus.currency.network.rawId)) { val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP) val formattedHash = getFormattedHash(transactionBytes) - if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && userWallet is UserWallet.Cold) { + if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES && + fromSwapCurrencyStatus.userWallet is UserWallet.Cold + ) { raise(ExpressDataError.TooLargeSolanaTransactionError) } getFeeDataForSolanaDexSwap( - network = fromToken.currency.network, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transactionBytes = transactionBytes, ) } else { getFeeDataForDexSwap( - network = fromToken.currency.network, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, transaction = transaction, - fromToken = fromToken.currency, ).map { fee -> - (fee as TransactionFeeResult.Loaded).fee - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) + (fee as TransactionFeeResult.Loaded).fee.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) }.bind() } } private suspend fun produceDexSwapDataError( + fromSwapCurrencyStatus: SwapCurrencyStatus, error: ExpressDataError, - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, amount: SwapAmount, ): SwapState.SwapError { - val rates = getQuotes(fromToken.currency.id) + val rates = getQuotes(fromSwapCurrencyStatus.currency.id) val fromTokenSwapInfo = TokenSwapInfo( + swapCurrencyStatus = fromSwapCurrencyStatus, tokenAmount = amount, - amountFiat = rates[fromToken.currency.id]?.fiatRate?.multiply(amount.value) - ?: BigDecimal.ZERO, - cryptoCurrencyStatus = fromToken, - account = fromAccount, + amountFiat = rates[fromSwapCurrencyStatus.currency.id]?.fiatRate?.multiply(amount.value) ?: BigDecimal.ZERO, ) return SwapState.SwapError( fromTokenSwapInfo, @@ -1882,15 +1607,14 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("CyclomaticComplexMethod") private suspend fun getFeeDataForDexSwap( - network: Network, + fromSwapCurrencyStatus: SwapCurrencyStatus, transaction: ExpressTransactionModel.DEX, - fromToken: CryptoCurrency, selectedToken: CryptoCurrencyStatus? = null, ): Either = either { val nativeBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, - networkId = network.rawId, - derivationPath = fromToken.network.derivationPath.value, + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromSwapCurrencyStatus.currency.network.rawId, + derivationPath = fromSwapCurrencyStatus.currency.network.derivationPath.value, ) // if native balance is zero - we can't calculate fee @@ -1900,7 +1624,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( try { val txAmountValue = transaction.txValue ?: error("unable to get txValue") - val amountToSend = createNativeAmountForDex(txAmountValue, fromToken.network) + val amountToSend = createNativeAmountForDex(txAmountValue, fromSwapCurrencyStatus.currency.network) // transaction.txValue is always native coin if (nativeBalance < amountToSend.value) { @@ -1909,7 +1633,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val extras = createTransactionExtrasUseCase( data = transaction.txData, - network = network, + network = fromSwapCurrencyStatus.currency.network, ).getOrNull() ?: error("unable to create extras") val transactionData = TransactionData.Uncompiled( @@ -1923,68 +1647,65 @@ internal class SwapInteractorImpl @AssistedInject constructor( getFeeForTokenUseCase( transactionData = transactionData, token = selectedToken.currency, - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, ).getOrNull()?.let { TransactionFeeResult.LoadedExtended(it) } ?: error("unable to calculate fee for token") } else { getFeeUseCase( transactionData = transactionData, - network = network, - userWallet = userWallet, + network = fromSwapCurrencyStatus.currency.network, + userWallet = fromSwapCurrencyStatus.userWallet, ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("unable to calculate fee") } } catch (_: IllegalStateException) { getEthSpecificFeeUseCase( - userWallet = userWallet, - cryptoCurrency = fromToken, + userWallet = fromSwapCurrencyStatus.userWallet, + cryptoCurrency = fromSwapCurrencyStatus.currency, gasLimit = transaction.gas, ).getOrNull()?.let { TransactionFeeResult.Loaded(it) } ?: error("can't get fee for getEthSpecificFeeUseCase") } } - private suspend fun getFeeDataForSolanaDexSwap(network: Network, transactionBytes: ByteArray): TransactionFee { + private suspend fun getFeeDataForSolanaDexSwap( + fromSwapCurrencyStatus: SwapCurrencyStatus, + transactionBytes: ByteArray, + ): TransactionFee { val transactionData = TransactionData.Compiled( value = TransactionData.Compiled.Data.Bytes(transactionBytes), ) return getFeeUseCase( transactionData = transactionData, - network = network, - userWallet = userWallet, + network = fromSwapCurrencyStatus.currency.network, + userWallet = fromSwapCurrencyStatus.userWallet, ).getOrNull() ?: error("unable to calculate fee") } @Suppress("LongParameterList", "MaxChainedCallsOnSameLine") private suspend fun updateBalances( provider: SwapProvider, - fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account?, - toTokenStatus: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: SwapAmount, toTokenAmount: SwapAmount, swapData: SwapDataModel?, txFeeSealedState: TxFeeSealedState, ): SwapState.QuotesLoadedState { - val fromToken = fromTokenStatus.currency - val toToken = toTokenStatus.currency - val nativeToken = getNativeToken(fromToken.network) + val fromToken = fromSwapCurrencyStatus.currency + val toToken = toSwapCurrencyStatus.currency + val nativeToken = getNativeToken(fromSwapCurrencyStatus) val rates = getQuotes(fromToken.id, toToken.id, nativeToken.id) return SwapState.QuotesLoadedState( fromTokenInfo = TokenSwapInfo( tokenAmount = fromTokenAmount, - account = fromAccount, - cryptoCurrencyStatus = fromTokenStatus, - amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value) - ?: BigDecimal.ZERO, + swapCurrencyStatus = fromSwapCurrencyStatus, + amountFiat = rates[fromToken.id]?.fiatRate?.multiply(fromTokenAmount.value) ?: BigDecimal.ZERO, ), toTokenInfo = TokenSwapInfo( tokenAmount = toTokenAmount, - cryptoCurrencyStatus = toTokenStatus, - account = toAccount, - amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value) - ?: BigDecimal.ZERO, + swapCurrencyStatus = toSwapCurrencyStatus, + amountFiat = rates[toToken.id]?.fiatRate?.multiply(toTokenAmount.value) ?: BigDecimal.ZERO, ), priceImpact = calculatePriceImpact( fromTokenAmount = fromTokenAmount.value, @@ -1998,9 +1719,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( is TxFeeSealedState.Component -> { when (txFeeSealedState.txFee.transactionFeeResult) { is TransactionFeeResult.Loaded -> - txFeeSealedState.txFee.transactionFeeResult.fee.toTxFeeState(fromToken, null) + txFeeSealedState.txFee.transactionFeeResult.fee.toTxFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + otherNativeFee = null, + ) is TransactionFeeResult.LoadedExtended -> - txFeeSealedState.txFee.transactionFeeResult.fee.transactionFee.toTxFeeState(fromToken, null) + txFeeSealedState.txFee.transactionFeeResult.fee.transactionFee.toTxFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + otherNativeFee = null, + ) } } is TxFeeSealedState.Legacy -> txFeeSealedState.txFeeState @@ -2011,36 +1738,31 @@ internal class SwapInteractorImpl @AssistedInject constructor( private suspend fun getFeeForCex( txFeeResult: Either?, - fromToken: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, ): TxFeeState { return txFeeResult?.fold( ifLeft = { TxFeeState.Empty }, ifRight = { txFee -> - txFee - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND) - .toTxFeeState(fromToken.currency, null) + txFee.patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_SEND).toTxFeeState(fromSwapCurrencyStatus, null) }, ) ?: TxFeeState.Empty } - @Suppress("LongParameterList", "LongMethod", "CanBeNonNullable") private suspend fun updatePermissionState( - networkId: String, - fromTokenStatus: CryptoCurrencyStatus, - fromAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, swapAmount: SwapAmount, quotesLoadedState: SwapState.QuotesLoadedState, - spenderAddress: String?, + quoteModel: QuoteModel, isAllowedToSpend: Boolean, ): SwapState { - val fromToken = fromTokenStatus.currency + val fromToken = fromSwapCurrencyStatus.currency if (isAllowedToSpend) { return quotesLoadedState.copy( permissionState = PermissionDataState.Empty, ) } // if token balance ZERO not show permission state to avoid user to spend money for fee - val isTokenZeroBalance = getTokenBalance(fromTokenStatus).value.signum() == 0 + val isTokenZeroBalance = getTokenBalance(fromSwapCurrencyStatus.status).value.signum() == 0 if (isTokenZeroBalance) { return quotesLoadedState.copy( permissionState = PermissionDataState.Empty, @@ -2051,84 +1773,25 @@ internal class SwapInteractorImpl @AssistedInject constructor( permissionState = PermissionDataState.PermissionLoading, ) } - // setting up amount for approve with given amount for swap [SwapApproveType.Limited] - requireNotNull( - fromTokenStatus.value.networkAddress?.defaultAddress?.value, - ) { "networkAddress cannot be null" } val allowanceInfo = getAllowanceInfoUseCase( - userWalletId = userWalletId, + userWalletId = fromSwapCurrencyStatus.userWalletId, cryptoCurrency = fromToken, - spenderAddress = requireNotNull(spenderAddress) { "spenderAddress cannot be null" }, + spenderAddress = requireNotNull(quoteModel.allowanceContract) { "spenderAddress cant be null" }, requiredAmount = swapAmount.value, ).getOrNull() - val amount = if (allowanceInfo is AllowanceInfo.ResetNeeded) { - BigDecimal.ZERO - } else { - swapAmount.value - } - - val approveTransaction = createApprovalTransactionUseCase( - cryptoCurrencyStatus = fromTokenStatus, - userWalletId = userWalletId, - amount = amount, - contractAddress = fromToken.getContractAddress(), - spenderAddress = spenderAddress, - ).getOrElse { error -> - TangemLogger.e("Failed to create approveTransaction", error) - return createSwapErrorWith( - fromToken = fromTokenStatus, - fromAccount = fromAccount, - amount = swapAmount, - includeFeeInAmount = IncludeFeeInAmount.Excluded, - expressDataError = ExpressDataError.UnknownError, - ) - } - - val feeData = getFeeUseCase( - transactionData = approveTransaction, - network = fromToken.network, - userWallet = userWallet, - ).getOrNull() ?: error("unable to calculate fee") - - val feeState = feeData - .patchTransactionFeeForSwap(INCREASE_GAS_LIMIT_FOR_DEX) - .toTxFeeState(fromToken, null) - - val fee = when (feeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> feeState.normalFee.fee.amount.value - is TxFeeState.SingleFeeState -> feeState.fee.fee.amount.value - } - val swapFeeState = getFeeState( - fee = fee, - spendAmount = SwapAmount.zeroSwapAmount(), - networkId = networkId, - fromTokenStatus = fromTokenStatus, - ) return quotesLoadedState.copy( - permissionState = PermissionDataState.PermissionReadyForRequest( - currency = fromToken.symbol, - amount = INFINITY_SYMBOL, - walletAddress = getWalletAddress(fromToken.network), - spenderAddress = getTokenAddress(fromToken), + permissionState = PermissionDataState.PermissionRequired( isResetApproval = allowanceInfo is AllowanceInfo.ResetNeeded, - requestApproveData = RequestApproveStateData( - fee = feeState, - fromTokenAmount = swapAmount, - spenderAddress = spenderAddress, - ), - ), - preparedSwapConfigState = quotesLoadedState.preparedSwapConfigState.copy( - feeState = swapFeeState, + spenderAddress = quoteModel.allowanceContract, ), ) } @Suppress("LongMethod") private suspend fun TransactionFee.toTxFeeState( - fromToken: CryptoCurrency, + fromSwapCurrencyStatus: SwapCurrencyStatus, otherNativeFee: BigDecimal?, ): TxFeeState { val otherNativeFeeValue = otherNativeFee ?: BigDecimal.ZERO @@ -2136,8 +1799,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( is TransactionFee.Choosable -> { val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO val feePriority = this.priority.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0] - val priorityFiatValue = getFormattedFiatFees(fromToken, feePriority)[0] + val normalFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feeNormal)[0] + val priorityFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feePriority)[0] val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feeNormal, @@ -2151,8 +1814,10 @@ internal class SwapInteractorImpl @AssistedInject constructor( // region otherNativeFee val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue val priorityFeeWithOtherNative = feePriority + otherNativeFeeValue - val normalFiatValueWithNative = getFormattedFiatFees(fromToken, normalFeeWithOtherNative)[0] - val priorityFiatValueWithNative = getFormattedFiatFees(fromToken, priorityFeeWithOtherNative)[0] + val normalFiatValueWithNative = + getFormattedFiatFees(fromSwapCurrencyStatus, normalFeeWithOtherNative)[0] + val priorityFiatValueWithNative = + getFormattedFiatFees(fromSwapCurrencyStatus, priorityFeeWithOtherNative)[0] val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( amount = normalFeeWithOtherNative, @@ -2190,14 +1855,15 @@ internal class SwapInteractorImpl @AssistedInject constructor( } is TransactionFee.Single -> { val feeNormal = this.normal.amount.value ?: BigDecimal.ZERO - val normalFiatValue = getFormattedFiatFees(fromToken, feeNormal)[0] + val normalFiatValue = getFormattedFiatFees(fromSwapCurrencyStatus, feeNormal)[0] val normalCryptoFee = amountFormatter.formatBigDecimalAmountToUI( amount = feeNormal, decimals = this.normal.amount.decimals, ) // region otherNativeFee val normalFeeWithOtherNative = feeNormal + otherNativeFeeValue - val normalFiatValueWithNative = getFormattedFiatFees(fromToken, normalFeeWithOtherNative)[0] + val normalFiatValueWithNative = + getFormattedFiatFees(fromSwapCurrencyStatus, normalFeeWithOtherNative)[0] val normalCryptoFeeWithNative = amountFormatter.formatBigDecimalAmountToUI( amount = normalFeeWithOtherNative, @@ -2285,9 +1951,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( val gasLimit = this.gasLimit val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals) ?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP) - val increasedGasLimit = gasLimit - .multiply(percentage.toBigInteger()) - .divide(hundredPercent) + val increasedGasLimit = gasLimit.multiply(percentage.toBigInteger()).divide(hundredPercent) val increasedAmount = this.amount.copy( value = increasedGasLimit.toBigDecimal().multiply(increasedGasPrice).movePointLeft(this.amount.decimals), ) @@ -2324,18 +1988,16 @@ internal class SwapInteractorImpl @AssistedInject constructor( } private suspend fun isBalanceEnough( - fromToken: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus, amount: SwapAmount, fee: BigDecimal?, ): Boolean { - val tokenBalance = getTokenBalance(fromToken).value - val feePaidCurrency = getFeePaidCurrency( - currency = fromToken.currency, - ) + val tokenBalance = getTokenBalance(fromSwapCurrencyStatus.status).value + val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) return when (feePaidCurrency) { is FeePaidCurrency.Token -> tokenBalance >= amount.value else -> { - if (fromToken.currency is CryptoCurrency.Token) { + if (fromSwapCurrencyStatus.currency is CryptoCurrency.Token) { tokenBalance >= amount.value } else { tokenBalance >= amount.value.plus(fee ?: BigDecimal.ZERO) @@ -2344,18 +2006,13 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } - private suspend fun getFeePaidCurrency(currency: CryptoCurrency): FeePaidCurrency { + private suspend fun getFeePaidCurrency(swapCurrencyStatus: SwapCurrencyStatus): FeePaidCurrency { return currenciesRepository.getFeePaidCurrency( - userWalletId = userWalletId, - network = currency.network, + userWalletId = swapCurrencyStatus.userWalletId, + network = swapCurrencyStatus.currency.network, ) } - private suspend fun getWalletAddress(network: Network): String { - return walletManagersFacade.getDefaultAddress(userWalletId, network) - ?: error("Address not found for network: ${network.id}") - } - private fun getTokenAddress(currency: CryptoCurrency): String { return when (currency) { is CryptoCurrency.Coin -> { @@ -2373,25 +2030,24 @@ internal class SwapInteractorImpl @AssistedInject constructor( @Suppress("LongMethod", "CyclomaticComplexMethod") private suspend fun getFeeState( + fromSwapCurrencyStatus: SwapCurrencyStatus, fee: BigDecimal?, spendAmount: SwapAmount, - networkId: String, - fromTokenStatus: CryptoCurrencyStatus, ): SwapFeeState { if (fee == null) { return SwapFeeState.NotEnough() } - + val fromCurrency = fromSwapCurrencyStatus.currency val percentsToFeeIncrease = BigDecimal.ONE - return when (val feePaidCurrency = getFeePaidCurrency(fromTokenStatus.currency)) { + return when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { FeePaidCurrency.Coin -> { val nativeTokenBalance = walletManagersFacade.getNativeTokenBalance( - userWalletId = userWalletId, - networkId = networkId, - derivationPath = fromTokenStatus.currency.network.derivationPath.value, + userWalletId = fromSwapCurrencyStatus.userWalletId, + networkId = fromCurrency.network.rawId, + derivationPath = fromCurrency.network.derivationPath.value, ) - val balanceToCheck = when (fromTokenStatus.currency) { + val balanceToCheck = when (fromCurrency) { is CryptoCurrency.Token -> nativeTokenBalance is CryptoCurrency.Coin -> { // need to check balance minus amount only if amount to swap in native token @@ -2401,23 +2057,21 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (balanceToCheck > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { - val nativeToken = getNativeToken(fromTokenStatus.currency.network) + val nativeToken = getNativeToken(fromSwapCurrencyStatus) SwapFeeState.NotEnough( - feeCurrency = nativeToken, currencyName = nativeToken.network.name, currencySymbol = nativeToken.symbol, ) } } FeePaidCurrency.SameCurrency -> { - val balance = fromTokenStatus.value.amount ?: return SwapFeeState.NotEnough() + val balance = fromSwapCurrencyStatus.status.value.amount ?: return SwapFeeState.NotEnough() if (balance.minus(spendAmount.value) > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { SwapFeeState.NotEnough( - feeCurrency = fromTokenStatus.currency, - currencyName = fromTokenStatus.currency.name, - currencySymbol = fromTokenStatus.currency.symbol, + currencyName = fromCurrency.name, + currencySymbol = fromCurrency.symbol, ) } } @@ -2425,20 +2079,7 @@ internal class SwapInteractorImpl @AssistedInject constructor( if (feePaidCurrency.balance > fee.multiply(percentsToFeeIncrease)) { SwapFeeState.Enough } else { - val tokens = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( - params = MultiWalletCryptoCurrenciesProducer.Params(userWalletId), - ) - .orEmpty() - - val token = tokens - .filterIsInstance() - .find { cryptoToken -> - cryptoToken.contractAddress.equals(feePaidCurrency.contractAddress, ignoreCase = true) && - cryptoToken.network.derivationPath == fromTokenStatus.currency.network.derivationPath - } - SwapFeeState.NotEnough( - feeCurrency = token, currencyName = feePaidCurrency.name, currencySymbol = feePaidCurrency.symbol, ) @@ -2447,8 +2088,8 @@ internal class SwapInteractorImpl @AssistedInject constructor( is FeePaidCurrency.FeeResource -> { val isFeeResourceEnough = currencyChecksRepository.checkIfFeeResourceEnough( amount = spendAmount.value, - userWalletId = userWalletId, - network = fromTokenStatus.currency.network, + userWalletId = fromSwapCurrencyStatus.userWalletId, + network = fromCurrency.network, ) if (isFeeResourceEnough) { @@ -2510,14 +2151,12 @@ internal class SwapInteractorImpl @AssistedInject constructor( val set = ids.mapNotNullTo(destination = hashSetOf(), transform = CryptoCurrency.ID::rawCurrencyId) .getQuotesOrEmpty() - return ids - .mapNotNull { id -> - val found = set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data } - ?: return@mapNotNull null + return ids.mapNotNull { id -> + val found = set.find { it.rawCurrencyId == id.rawCurrencyId && it.value is QuoteStatus.Data } + ?: return@mapNotNull null - id to found.value as QuoteStatus.Data - } - .toMap() + id to found.value as QuoteStatus.Data + }.toMap() } private suspend fun Set.getQuotesOrEmpty(): Set { @@ -2570,6 +2209,40 @@ internal class SwapInteractorImpl @AssistedInject constructor( } } + // region temporary. will be removed + private fun CryptoCurrency.getContractAddress(): String { + return when (this) { + is CryptoCurrency.Token -> this.contractAddress + is CryptoCurrency.Coin -> "0" + } + } + + private fun ExpressProvider.toSwapProvider(): SwapProvider { + return SwapProvider( + providerId = providerId, + rateTypes = rateTypes.map { rateType -> + when (rateType) { + ExpressRateType.Float -> RateType.FLOAT + ExpressRateType.Fixed -> RateType.FIXED + } + }, + name = name, + type = when (type) { + ExpressProviderType.DEX -> ExchangeProviderType.DEX + ExpressProviderType.CEX -> ExchangeProviderType.CEX + ExpressProviderType.DEX_BRIDGE -> ExchangeProviderType.DEX_BRIDGE + ExpressProviderType.ONRAMP -> error("Invalid provider type") + }, + imageLarge = imageLarge, + termsOfUse = termsOfUse, + privacyPolicy = privacyPolicy, + isRecommended = isRecommended, + slippage = slippage, + isExtraIdSupported = isExtraIdSupported, + ) + } + // endregion + companion object { private const val INCREASE_GAS_LIMIT_FOR_DEX = 112 // 12% private const val INCREASE_GAS_LIMIT_FOR_SEND = 105 // 5% @@ -2578,12 +2251,6 @@ internal class SwapInteractorImpl @AssistedInject constructor( private val PRICE_IMPACT_AMOUNT_LOW_THRESHOLD = 100_000.toBigDecimal() // in USD private val PRICE_IMPACT_LOW_THRESHOLD = 0.1.toBigDecimal() // 10% private val PRICE_IMPACT_HIGH_THRESHOLD = 0.5.toBigDecimal() // 50% - private const val INFINITY_SYMBOL = "∞" - } - - @AssistedFactory - interface Factory : SwapInteractor.Factory { - override fun create(selectedWalletId: UserWalletId): SwapInteractorImpl } } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt index 0720aa7abf..03b73c515b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapTransactionRepository.kt @@ -14,7 +14,8 @@ interface SwapTransactionRepository { @Suppress("LongParameterList") suspend fun storeTransaction( - userWalletId: UserWalletId, + fromUserWalletId: UserWalletId, + toUserWalletId: UserWalletId, fromCryptoCurrency: CryptoCurrency, toCryptoCurrency: CryptoCurrency, fromAccount: Account?, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index c197aac5a9..9a38c5448b 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.domain.* @@ -23,7 +24,11 @@ interface SwapRepository { isIgnoreExpress: Boolean = false, ): PairsWithProviders - suspend fun getExchangeStatus(userWallet: UserWallet, txId: String): Either + suspend fun getExchangeStatus( + userWallet: UserWallet?, + userWalletId: UserWalletId, + txId: String, + ): Either @Suppress("LongParameterList") suspend fun findBestQuote( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 26b53125e0..c753a3381d 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -1,6 +1,10 @@ package com.tangem.feature.swap.domain.di -import com.tangem.feature.swap.domain.* +import com.tangem.feature.swap.domain.AllowPermissionsHandler +import com.tangem.feature.swap.domain.AllowPermissionsHandlerImpl +import com.tangem.feature.swap.domain.SwapInteractor +import com.tangem.feature.swap.domain.SwapInteractorImpl +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -16,20 +20,13 @@ internal class SwapDomainModule { fun provideAllowPermissionsHandler(): AllowPermissionsHandler { return AllowPermissionsHandlerImpl() } +} - @Provides - @Singleton - fun provideSwapInteractorFactory(factory: SwapInteractorImpl.Factory): SwapInteractor.Factory { - return factory - } +@Module +@InstallIn(SingletonComponent::class) +internal interface SwapDomainBindModule { - @Provides + @Binds @Singleton - fun provideInitialToCurrencyResolver( - swapTransactionRepository: SwapTransactionRepository, - ): InitialToCurrencyResolver { - return DefaultInitialToCurrencyResolver( - swapTransactionRepository = swapTransactionRepository, - ) - } + fun provideSwapInteractor(swapInteractor: SwapInteractorImpl): SwapInteractor } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt deleted file mode 100644 index 383d8fe28f..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/NetworkInfo.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.feature.swap.domain.models.domain - -data class NetworkInfo( - val name: String, - val blockchainId: String, -) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt deleted file mode 100644 index e07d257628..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PermissionOptions.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.feature.swap.domain.models.domain - -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData -import com.tangem.feature.swap.domain.models.ui.TxFee - -/** - * Permission options - * - * @param approveData tx data to give approve, it loaded from 1inch in findBestQuote if needed - * @param forTokenContractAddress token contract address for which needs permission - * @param fromTokenStatus which token will be swapping - * @param approveType unlimited or tx amount approve - * @param txFee fee for tx - */ -data class PermissionOptions( - val approveData: RequestApproveStateData, - val forTokenContractAddress: String, - val fromTokenStatus: CryptoCurrencyStatus, - val spenderAddress: String, - val approveType: SwapApproveType, - val txFee: TxFee, -) \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt index ec5293ae96..8f1eab0d73 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/PreparedSwapConfigState.kt @@ -5,12 +5,10 @@ import com.tangem.feature.swap.domain.models.SwapAmount /** * Prepared swap config state that contains flags to determine * - * @property isAllowedToSpend shows is token allowed to spend * @property isBalanceEnough shows is balance of token enough */ // todo Refactor this state data class PreparedSwapConfigState( - val isAllowedToSpend: Boolean, val isBalanceEnough: Boolean, val feeState: SwapFeeState, val hasOutgoingTransaction: Boolean, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt index 31236c6313..1480a38558 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SavedSwapTransactionListModel.kt @@ -8,7 +8,8 @@ import com.tangem.domain.models.currency.CryptoCurrency import java.math.BigDecimal data class SavedSwapTransactionListModel( - val userWalletId: String, + val fromUserWalletId: String, + val toUserWalletId: String, val fromCryptoCurrencyId: String, val toCryptoCurrencyId: String, val fromCryptoCurrency: CryptoCurrency, @@ -25,7 +26,9 @@ data class SavedSwapTransactionListModel( @JsonClass(generateAdapter = true) data class SavedSwapTransactionListModelInner( @Json(name = "userWalletId") - val userWalletId: String, + val fromUserWalletId: String, + @Json(name = "toUserWalletId") + val toUserWalletId: String = fromUserWalletId, @Json(name = "fromCryptoCurrencyId") val fromCryptoCurrencyId: String, @Json(name = "toCryptoCurrencyId") diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt deleted file mode 100644 index 21ded70713..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapApproveType.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tangem.feature.swap.domain.models.domain - -enum class SwapApproveType { - LIMITED, UNLIMITED -} - -fun SwapApproveType.getNameForAnalytics(): String { - return when (this) { - SwapApproveType.LIMITED -> "Transaction" - SwapApproveType.UNLIMITED -> "Unlimited" - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt index e4a002e630..b4ceb30a64 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapFeeState.kt @@ -1,11 +1,8 @@ package com.tangem.feature.swap.domain.models.domain -import com.tangem.domain.models.currency.CryptoCurrency - sealed class SwapFeeState { data object Enough : SwapFeeState() data class NotEnough( - val feeCurrency: CryptoCurrency? = null, val currencyName: String? = null, val currencySymbol: String? = null, ) : SwapFeeState() diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt index f65dd60a98..9c1d643ad4 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/domain/SwapPairLeast.kt @@ -59,11 +59,21 @@ data class SwapProvider( @JsonClass(generateAdapter = false) enum class ExchangeProviderType(val providerName: String) { - @Json(name = "DEX") DEX("DEX"), + @Json(name = "DEX") + DEX("DEX"), - @Json(name = "CEX") CEX("CEX"), + @Json(name = "CEX") + CEX("CEX"), - @Json(name = "DEX_BRIDGE") DEX_BRIDGE("DEX/Bridge"), + @Json(name = "DEX_BRIDGE") + DEX_BRIDGE("DEX/Bridge"), + ; + + companion object { + fun getSwapProviderTypes(): List { + return listOf(CEX, DEX, DEX_BRIDGE) + } + } } /** @@ -73,7 +83,9 @@ enum class ExchangeProviderType(val providerName: String) { */ @JsonClass(generateAdapter = false) enum class RateType { - @Json(name = "FLOAT") FLOAT, + @Json(name = "FLOAT") + FLOAT, - @Json(name = "FIXED") FIXED, + @Json(name = "FIXED") + FIXED, } \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index d538156362..562869ee5a 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -3,8 +3,8 @@ package com.tangem.feature.swap.domain.models.ui import androidx.compose.runtime.Immutable import com.tangem.blockchain.common.transaction.Fee import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.models.ExpressDataError @@ -23,7 +23,6 @@ sealed interface SwapState { val toTokenInfo: TokenSwapInfo, val priceImpact: PriceImpact, val preparedSwapConfigState: PreparedSwapConfigState = PreparedSwapConfigState( - isAllowedToSpend = false, isBalanceEnough = false, feeState = SwapFeeState.NotEnough(), hasOutgoingTransaction = false, @@ -81,17 +80,11 @@ data class PriceImpact( sealed class PermissionDataState { - data class PermissionReadyForRequest( - val currency: String, - val amount: String, - val walletAddress: String, - val spenderAddress: String, - val requestApproveData: RequestApproveStateData, + data class PermissionRequired( val isResetApproval: Boolean, + val spenderAddress: String, ) : PermissionDataState() - object PermissionFailed : PermissionDataState() - object PermissionLoading : PermissionDataState() object Empty : PermissionDataState() @@ -100,8 +93,7 @@ sealed class PermissionDataState { data class TokenSwapInfo( val tokenAmount: SwapAmount, val amountFiat: BigDecimal, - val cryptoCurrencyStatus: CryptoCurrencyStatus, - val account: Account?, + val swapCurrencyStatus: SwapCurrencyStatus, ) data class RequestApproveStateData( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt index b1380f6a3a..8f5d9f4fbc 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapTransactionState.kt @@ -1,9 +1,8 @@ package com.tangem.feature.swap.domain.models.ui -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.pay.TangemPayWithdrawExchangeState +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount @@ -36,10 +35,8 @@ sealed class SwapTransactionState { ) : SwapTransactionState() { data class StoreTransactionData( - val currencyToSend: CryptoCurrencyStatus, - val currencyToGet: CryptoCurrencyStatus, - val fromAccount: Account?, - val toAccount: Account?, + val fromSwapCurrencyStatus: SwapCurrencyStatus, + val toSwapCurrencyStatus: SwapCurrencyStatus, val amount: SwapAmount, val swapProvider: SwapProvider, val swapDataModel: SwapDataModel, diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index ca51e0e384..6c456d8519 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.feature.swap.presentation" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.commonFeatures.api) @@ -59,6 +63,8 @@ dependencies { implementation(projects.domain.account.status) implementation(projects.domain.visa) implementation(projects.domain.markets) + implementation(projects.domain.swap) + implementation(projects.domain.swap.models) /** Feature modules */ implementation(projects.features.swap.domain) @@ -106,4 +112,8 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Test */ + testImplementation(projects.test.core) + testRuntimeOnly(deps.test.junit5.engine) } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 09d6310350..6f961c4f36 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -1,30 +1,37 @@ package com.tangem.feature.swap -import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop import com.arkivanov.essenty.lifecycle.subscribe -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.R +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.isHotWallet import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.model.SwapModel -import com.tangem.feature.swap.router.SwapNavScreen +import com.tangem.feature.swap.models.SwapPermissionUM +import com.tangem.feature.swap.router.SwapRoute import com.tangem.feature.swap.ui.SwapScreen import com.tangem.feature.swap.ui.SwapSuccessScreen import com.tangem.features.approval.api.GiveApprovalComponent @@ -46,17 +53,24 @@ internal class DefaultSwapComponent @AssistedInject constructor( private val chooseTokenComponentFactory: ChooseTokenComponent.Factory, ) : SwapComponent, AppComponentContext by appComponentContext { - private val model: SwapModel = getOrCreateModel(params) + private val stackNavigation = StackNavigation() + private val innerRouter = InnerRouter( + stackNavigation = stackNavigation, + popCallback = { onChildBack() }, + ) - // todo swap create InnerRouter - private val chooseTokenComponent by lazy { - chooseTokenComponentFactory.create( - context = child("chooseTokenComponent"), - params = ChooseTokenComponent.Params( - bridge = model.chooseTokenBridge, - ), - ) - } + private val model: SwapModel = getOrCreateModel(params, router = innerRouter) + + private val childStack = childStack( + key = STACK_KEY, + source = stackNavigation, + serializer = null, + initialConfiguration = SwapRoute.Main, + handleBackButton = true, + childFactory = { route, factoryContext -> + createChild(route, childByContext(factoryContext)) + }, + ) private val approvalSlot = childSlot( key = APPROVAL_SLOT_KEY, @@ -81,7 +95,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( } private val slotNavigation = SlotNavigation() - private val childSlot = childSlot( + private val feeSelectorSlot = childSlot( source = slotNavigation, serializer = null, key = FEE_SELECTOR_SLOT_KEY, @@ -112,6 +126,19 @@ internal class DefaultSwapComponent @AssistedInject constructor( ) } + private fun createChild(route: SwapRoute, factoryContext: AppComponentContext): ComposableContentComponent = + when (route) { + is SwapRoute.Main -> SwapMainChild() + is SwapRoute.Success -> SwapSuccessChild() + is SwapRoute.SelectToken -> { + val bridge = if (route.isFromDirection) model.chooseFromTokenBridge else model.chooseToTokenBridge + chooseTokenComponentFactory.create( + context = factoryContext, + params = ChooseTokenComponent.Params(bridge = bridge), + ) + } + } + data class FeeSelectorConfig( val sendingCurrencyStatus: CryptoCurrencyStatus, val feeCurrencyStatus: CryptoCurrencyStatus, @@ -121,7 +148,7 @@ internal class DefaultSwapComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle() - val fromCryptoCurrency by remember { derivedStateOf { dataState.fromCryptoCurrency } } + val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } } val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } } val shouldHideBlock by remember { derivedStateOf { toBigDecimalOrZero(dataState.amount).isZero() || model.uiState.isInsufficientFunds } @@ -158,67 +185,78 @@ internal class DefaultSwapComponent @AssistedInject constructor( ) } - val feeSelectorChildStackState by childSlot.subscribeAsState() - val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance + val stackState by childStack.subscribeAsState() - Crossfade( + Children( + stack = stackState, modifier = Modifier.background(TangemTheme.colors.background.secondary), - targetState = model.currentScreen, - label = "", - ) { screen -> - when (screen) { - SwapNavScreen.Main -> SwapScreen( - stateHolder = model.uiState, - feeSelectorBlockComponent = feeSelectorBlockComponent, - ) - SwapNavScreen.Success -> { - val successState = model.uiState.successState - val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle() - if (successState != null) { - SwapSuccessScreen( - state = successState, - feeSelectorUM = feeSelectorState, - onBack = model.uiState.onBackClicked, - ) - } else { - SwapScreen( - stateHolder = model.uiState, - feeSelectorBlockComponent = feeSelectorBlockComponent, - ) - } - } - SwapNavScreen.SelectToken -> chooseTokenComponent.Content(Modifier) - } + animation = stackAnimation { fade() }, + ) { child -> + child.instance.Content(Modifier) } val approvalSlotState by approvalSlot.subscribeAsState() approvalSlotState.child?.instance?.BottomSheet() } - fun getApprovalParams(): GiveApprovalComponent.Params? { - val permissionState = model.uiState.permissionState as? GiveTxPermissionState.ReadyForRequest - ?: return null - val fromCryptoCurrency = model.dataState.fromCryptoCurrency ?: return null + private inner class SwapMainChild : ComposableContentComponent { + @Composable + override fun Content(modifier: Modifier) { + val feeSelectorChildState by feeSelectorSlot.subscribeAsState() + val feeSelectorBlockComponent = feeSelectorChildState.child?.instance + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) + } + } + + private inner class SwapSuccessChild : ComposableContentComponent { + @Composable + override fun Content(modifier: Modifier) { + val successState = model.uiState.successState + val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle() + if (successState != null) { + SwapSuccessScreen( + state = successState, + feeSelectorUM = feeSelectorState, + onBack = router::pop, + ) + } else { + val feeSelectorChildState by feeSelectorSlot.subscribeAsState() + val feeSelectorBlockComponent = feeSelectorChildState.child?.instance + SwapScreen( + stateHolder = model.uiState, + feeSelectorBlockComponent = feeSelectorBlockComponent, + ) + } + } + } + + private fun getApprovalParams(): GiveApprovalComponent.Params? { + val permissionState = model.uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null + val fromSwapCurrencyStatus = model.dataState.fromSwapCurrencyStatus ?: return null val feeCryptoCurrency = model.dataState.feePaidCryptoCurrency ?: return null val providerName = model.dataState.selectedProvider?.name.orEmpty() + val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet return GiveApprovalComponent.Params( userWalletId = params.userWalletId, - cryptoCurrencyStatus = fromCryptoCurrency, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, feeCryptoCurrencyStatus = feeCryptoCurrency, amount = model.dataState.amount.orEmpty(), - spenderAddress = requireNotNull(model.dataState.approveDataModel).spenderAddress, + spenderAddress = permissionState.spenderAddress, amountFooter = if (permissionState.isResetApproval) { resourceReference(R.string.update_approval_permission_subtitle) } else { resourceReference( id = R.string.give_permission_swap_subtitle, - formatArgs = wrappedList(providerName, permissionState.currency), + formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol), ) }, feeFooter = resourceReference(R.string.swap_give_permission_fee_footer), isResetApproval = permissionState.isResetApproval, - isHoldToConfirm = model.isHoldToConfirmEnabled, + isHoldToConfirm = isHoldToConfirm, callback = model.approvalCallback, ) } @@ -227,13 +265,24 @@ internal class DefaultSwapComponent @AssistedInject constructor( return bigDecimalString?.replace(",", ".")?.toBigDecimalOrNull() ?: BigDecimal.ZERO } + private fun onChildBack() { + val isEmptyStack = childStack.value.backStack.isEmpty() + val isSuccess = model.uiState.successState != null + + val isPopSend = isEmptyStack || isSuccess + when { + isPopSend -> router.pop() + else -> stackNavigation.pop() + } + } + @AssistedFactory interface Factory : SwapComponent.Factory { override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent } private companion object { - const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot" + const val STACK_KEY = "swapStack" const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot" const val APPROVAL_SLOT_KEY = "approvalSlot" } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index e0fccb451d..12d6bb07b8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -1,6 +1,5 @@ package com.tangem.feature.swap.analytics -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO @@ -38,11 +37,6 @@ sealed class SwapEvents( class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked") - class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents( - event = "Choose Token Screen Opened", - params = mapOf("Available tokens" to if (hasAvailableTokens) "Yes" else "No"), - ) - class ChooseTokenScreenResult( val isTokenChosen: Boolean, val token: String? = null, @@ -72,23 +66,6 @@ sealed class SwapEvents( ), ) - class ButtonPermissionApproveClicked( - val sendToken: String, - val receiveToken: String, - val approveType: ApproveType, - val provider: SwapProvider, - ) : SwapEvents( - event = "Button - Permission Approve", - params = mapOf( - "Send Token" to sendToken, - "Receive Token" to receiveToken, - "Type" to if (approveType == ApproveType.LIMITED) "Current Transaction" else "Unlimited", - "Provider" to provider.name, - ), - ) - - class ButtonPermissionCancelClicked : SwapEvents(event = "Button - Permission Cancel") - class ButtonSwipeClicked : SwapEvents(event = "Button - Swipe") @Suppress("NullableToStringCall", "LongParameterList") diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt index 41c4423225..4ac81de2fe 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt @@ -39,7 +39,7 @@ interface ChooseTokenBridge : ChooseTokenBridgeLegacy, ChooseTokenBridgeInternal companion object { val SwapFrom = Settings( title = resourceReference(R.string.swapping_from_title), - isShowMarketBlock = false, + isShowMarketBlock = true, isShowPaymentAccount = true, ) val SwapTo = Settings( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt index 4b97a57ebe..750f2de85c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt @@ -13,6 +13,7 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel +import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenScreen import com.tangem.feature.swap.models.AddToPortfolioRoute import com.tangem.feature.swap.ui.SwapSelectTokenScreen import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent @@ -42,10 +43,13 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor( val bottomSheet by bottomSheetSlot.subscribeAsState() stateOld?.let { stateHolder -> SwapSelectTokenScreen(state = stateHolder, onBack = { model.onBackClicked() }) + bottomSheet.child?.instance?.BottomSheet() + // if old shown we should not show new screen + return } - // todo swap uncomment - // val state by model.state.collectAsStateWithLifecycle() - // ChooseTokenScreen(state = state) + + val state by model.state.collectAsStateWithLifecycle() + ChooseTokenScreen(state = state) bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt new file mode 100644 index 0000000000..a245cc7a9f --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/InitialCurrenciesResolver.kt @@ -0,0 +1,225 @@ +package com.tangem.feature.swap.model + +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition +import com.tangem.utils.extensions.orZero +import com.tangem.utils.isNullOrZero +import javax.inject.Inject + +/** + * Resolves the initial FROM and TO currencies when the swap screen opens. + * + * Selection rules when [initialCryptoCurrency][CryptoCurrency] is provided: + * - [CurrencyPosition.FROM] — places the currency as FROM, TO is null. + * - [CurrencyPosition.TO] — places the currency as TO, FROM is null. + * - [CurrencyPosition.ANY] — auto-places based on availability and balance: + * - available with balance → FROM. + * - available without balance or unavailable without balance → TO, + * and the best candidate from the SAME account as the initial currency is selected as FROM + * (the search is scoped to that account only, not the whole portfolio). + * - unavailable with balance → FROM. + * + * When no initial currency is provided, selects the best token from crypto portfolio accounts: + * 1. If available tokens with balance exist — the available token with the highest fiat balance. + * 2. If available tokens exist but none have balance — the first token from the first account. + * 3. If no available tokens exist but tokens with balance exist — the token with the highest fiat balance. + * 4. If no tokens have balance — the first token from the first account. + */ +internal class InitialCurrenciesResolver @Inject constructor( + private val getUserWalletUseCase: GetUserWalletUseCase, + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val rampStateManager: RampStateManager, +) { + + /** + * Resolves the initial FROM/TO currency pair for the swap screen. + * + * @param userWalletId the wallet to resolve currencies for + * @param initialCryptoCurrency pre-selected currency, or null to auto-select + * @param swapCurrencyPosition preferred position for the initial currency + * @return pair of (from, to) [SwapCurrencyStatus]; either or both may be null + */ + suspend operator fun invoke( + userWalletId: UserWalletId, + initialCryptoCurrency: CryptoCurrency?, + swapCurrencyPosition: CurrencyPosition, + isPaymentAccount: Boolean, + ): Pair { + val walletAccountList = getWalletAccountCurrencyStatusList(userWalletId) + val cryptoPortfolioAccounts = walletAccountList.filterKeys { accountStatus -> + accountStatus is AccountStatus.CryptoPortfolio + }.mapKeys { (key, _) -> key as AccountStatus.CryptoPortfolio } + val cryptoPaymentAccounts = walletAccountList.filterKeys { accountStatus -> + accountStatus is AccountStatus.Payment + } + + val cryptoCurrencyList = cryptoPortfolioAccounts.values.flatten() + + return if (initialCryptoCurrency != null) { + val selectedSwapCurrencyStatus = if (isPaymentAccount) { + cryptoPaymentAccounts + } else { + cryptoPortfolioAccounts + }.firstNotNullOfOrNull { (_, currencyList) -> + currencyList.firstOrNull { currencyStatus -> + currencyStatus.currency.id == initialCryptoCurrency.id + } + } + + if (selectedSwapCurrencyStatus == null) { + null to null + } else { + placeSelectedCurrency( + selectedSwapCurrencyStatus = selectedSwapCurrencyStatus, + swapCurrencyPosition = swapCurrencyPosition, + cryptoPortfolioAccountsMap = cryptoPortfolioAccounts, + ) + } + } else { + selectCryptoCurrency( + cryptoPortfolioAccountsMap = cryptoPortfolioAccounts, + cryptoCurrencyList = cryptoCurrencyList, + ) to null + } + } + + /** + * Builds a map of [AccountStatus] to their [SwapCurrencyStatus] lists, + * enriching each currency with its swap availability from [RampStateManager]. + */ + private suspend fun getWalletAccountCurrencyStatusList( + userWalletId: UserWalletId, + ): Map> { + val userWallet = getUserWalletUseCase(userWalletId).getOrNull() ?: return emptyMap() + + val walletAccountCurrencyStatuses = singleAccountStatusListSupplier.getSyncOrNull( + SingleAccountStatusListProducer.Params(userWalletId), + )?.accountStatuses.orEmpty() + + return walletAccountCurrencyStatuses.associateWith { accountStatus -> + val currencyStatuses = when (accountStatus) { + is AccountStatus.CryptoPortfolio -> accountStatus.flattenCurrencies() + is AccountStatus.Payment -> getPaymentAccountCurrencies(accountStatus) + } + val availabilityStates = rampStateManager.availableForSwap( + userWalletId, + currencyStatuses.map { it.currency }, + ) + currencyStatuses.map { cryptoCurrencyStatus -> + SwapCurrencyStatus( + userWallet = userWallet, + account = accountStatus.account, + status = cryptoCurrencyStatus, + isAvailableForSwap = availabilityStates[cryptoCurrencyStatus.currency] == + ScenarioUnavailabilityReason.None, + ) + } + } + } + + private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List { + val paymentCryptoCurrencyStatus = when (val statusValue = accountStatus.value) { + is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus + else -> null + } + + return listOfNotNull(paymentCryptoCurrencyStatus) + } + + /** + * Places the [selectedSwapCurrencyStatus] into the FROM or TO slot based on [swapCurrencyPosition]. + * + * For [CurrencyPosition.ANY], the position is determined by availability and balance: + * currencies that are available with balance go to FROM; otherwise, the selected currency + * goes to TO and a best-candidate FROM is resolved via [selectCryptoCurrency] — scoped to the + * SAME account that the selected currency belongs to, so we never pull a FROM candidate from a + * different account in the portfolio. + */ + private fun placeSelectedCurrency( + selectedSwapCurrencyStatus: SwapCurrencyStatus, + swapCurrencyPosition: CurrencyPosition, + cryptoPortfolioAccountsMap: Map>, + ): Pair { + return when (swapCurrencyPosition) { + CurrencyPosition.FROM -> { + selectedSwapCurrencyStatus to null + } + CurrencyPosition.TO -> { + null to selectedSwapCurrencyStatus + } + CurrencyPosition.ANY -> { + val isAvailable = selectedSwapCurrencyStatus.isAvailableForSwap + val hasBalance = !selectedSwapCurrencyStatus.status.value.fiatAmount.isNullOrZero() + if (isAvailable && hasBalance) { + selectedSwapCurrencyStatus to null + } else if (isAvailable || !hasBalance) { + val selectedCurrency = selectedSwapCurrencyStatus.currency + val selectedAccountId = selectedSwapCurrencyStatus.account.accountId + val sameAccountEntry = cryptoPortfolioAccountsMap.entries + .firstOrNull { (accountStatus, _) -> accountStatus.account.accountId == selectedAccountId } + + if (sameAccountEntry == null) { + null to selectedSwapCurrencyStatus + } else { + val scopedList = sameAccountEntry.value + .filterNot { it.currency.isSameTokenAs(selectedCurrency) } + selectCryptoCurrency( + cryptoPortfolioAccountsMap = mapOf(sameAccountEntry.key to scopedList), + cryptoCurrencyList = scopedList, + ) to selectedSwapCurrencyStatus + } + } else { + selectedSwapCurrencyStatus to null + } + } + } + } + + /** + * Checks whether two currencies refer to the same asset on the same network, regardless of the + * owning account. Two instances of the same token in different accounts have distinct + * [CryptoCurrency.ID] values (their derivation path differs), so id equality is not sufficient + * to detect duplicates when auto-picking a FROM candidate. + */ + private fun CryptoCurrency.isSameTokenAs(other: CryptoCurrency): Boolean { + return id.rawNetworkId == other.id.rawNetworkId && + id.contractAddress == other.id.contractAddress + } + + /** + * Selects the best token from the crypto portfolio when no initial currency is specified. + * + * Prioritizes available-for-swap tokens. Among the candidates, picks the one with the highest + * [fiatAmount][CryptoCurrencyStatus.Value.fiatAmount]. Falls back to the first token from the + * first account if no candidate has a positive balance. + */ + private fun selectCryptoCurrency( + cryptoPortfolioAccountsMap: Map>, + cryptoCurrencyList: List, + ): SwapCurrencyStatus? { + return if (cryptoCurrencyList.isEmpty()) { + null + } else { + val hasAvailable = cryptoCurrencyList.any { it.isAvailableForSwap } + val candidates = if (hasAvailable) { + cryptoCurrencyList.filter { it.isAvailableForSwap } + } else { + cryptoCurrencyList + } + candidates + .filter { !it.status.value.fiatAmount.isNullOrZero() } + .maxByOrNull { it.status.value.fiatAmount.orZero() } + ?: cryptoPortfolioAccountsMap.entries.firstOrNull()?.value?.firstOrNull() + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 074fe771f4..ed470d4a60 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -12,30 +12,28 @@ import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources import com.tangem.core.analytics.models.Basic -import com.tangem.core.analytics.models.event.SwapAnalyticsEvent 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.navigation.url.UrlOpener import com.tangem.core.ui.R import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction import com.tangem.core.ui.utils.InputNumberFormatter import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore -import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase -import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -47,12 +45,12 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.BlockchainErrorInfo import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.derivationIndex 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.wallet.UserWalletId -import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.promo.ShouldShowStoriesUseCase @@ -60,7 +58,7 @@ import com.tangem.domain.promo.models.StoryContentIds import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions -import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase @@ -69,10 +67,10 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge +import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.domain.AllowPermissionsHandler @@ -80,29 +78,33 @@ import com.tangem.feature.swap.domain.SwapInteractor import com.tangem.feature.swap.domain.TransactionFeeResult import com.tangem.feature.swap.domain.TxFeeSealedState import com.tangem.feature.swap.domain.models.ExpressDataError -import com.tangem.feature.swap.domain.models.ExpressException import com.tangem.feature.swap.domain.models.SwapAmount -import com.tangem.feature.swap.domain.models.domain.* +import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType +import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.* -import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.SwapAlertUM +import com.tangem.feature.swap.models.SwapStateHolder +import com.tangem.feature.swap.models.TokenSelectionDirection +import com.tangem.feature.swap.models.UiActions import com.tangem.feature.swap.models.states.SwapNotificationUM -import com.tangem.feature.swap.router.SwapNavScreen -import com.tangem.feature.swap.router.SwapRouter +import com.tangem.feature.swap.router.SwapRoute import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation import com.tangem.feature.swap.utils.getContractAddress import com.tangem.features.approval.api.GiveApprovalComponent -import com.tangem.features.approval.api.GiveApprovalFeatureToggles 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.utils.Provider -import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* import com.tangem.utils.isNullOrZero import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.* +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormat @@ -112,72 +114,62 @@ import javax.inject.Inject typealias SuccessLoadedSwapData = Map -@OptIn(ExperimentalCoroutinesApi::class) @Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class SwapModel @Inject constructor( paramsContainer: ParamsContainer, + getUserCountryUseCase: GetUserCountryUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + chooseTokenBridgeFactory: ChooseTokenBridge.Factory, + private val router: Router, + private val appRouter: AppRouter, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsErrorEventHandler: AnalyticsErrorHandler, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase, private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase, - private val getUserWalletUseCase: GetUserWalletUseCase, private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase, - getUserCountryUseCase: GetUserCountryUseCase, - getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, - swapInteractorFactory: SwapInteractor.Factory, - private val urlOpener: UrlOpener, - router: AppRouter, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, + private val swapInteractor: SwapInteractor, + private val urlOpener: UrlOpener, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, - private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, - private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase, + private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger, private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase, private val appsFlyerStore: AppsFlyerStore, private val messageSender: UiMessageSender, - private val paymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, + private val initialCurrenciesResolver: InitialCurrenciesResolver, private val allowPermissionsHandler: AllowPermissionsHandler, - chooseTokenBridgeFactory: ChooseTokenBridge.Factory, - giveApprovalFeatureToggles: GiveApprovalFeatureToggles, ) : Model() { private val params = paramsContainer.require() - private val initialCurrencyFrom = params.currencyFrom - private val initialCurrencyTo = params.currencyTo - private val userWalletId = params.userWalletId - private val isInitiallyReversed = params.isInitialReverseOrder + private val initialCryptoCurrency = params.cryptoCurrency private val tangemPayInput = params.tangemPayInput - private val userWallet by lazy { - requireNotNull( - getUserWalletUseCase(userWalletId).getOrNull(), - ) { "No wallet found for id: $userWalletId" } - } - private val swapInteractor = swapInteractorFactory.create(userWalletId) - - val isHoldToConfirmEnabled: Boolean = userWallet.isHotWallet - - private lateinit var initialFromStatus: CryptoCurrencyStatus - private var initialToStatus: CryptoCurrencyStatus? = null - private var isBalanceHidden = true + private var isAccountsMode: Boolean = false private val selectedAppCurrencyFlow: StateFlow = createSelectedAppCurrencyFlow() - val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + val chooseFromTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( + modelScope = modelScope, + settings = ChooseTokenBridge.Settings.SwapFrom, + analyticsPayload = setOf( + ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value), + ), + ) + val chooseToTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create( modelScope = modelScope, settings = ChooseTokenBridge.Settings.SwapTo, analyticsPayload = setOf( @@ -186,7 +178,6 @@ internal class SwapModel @Inject constructor( ) private val stateBuilder = StateBuilder( - userWalletProvider = Provider { userWallet }, actions = createUiActions(), isBalanceHiddenProvider = Provider { isBalanceHidden }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), @@ -194,11 +185,11 @@ internal class SwapModel @Inject constructor( iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, ) - private val inputNumberFormatter = - InputNumberFormatter( - NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat - ?: error("NumberFormat is not DecimalFormat"), - ) + private val inputNumberFormatter = InputNumberFormatter( + NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat + ?: error("NumberFormat is not DecimalFormat"), + ) + private val amountDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() @@ -209,37 +200,15 @@ internal class SwapModel @Inject constructor( dataStateStateFlow.value = value } - var uiState: SwapStateHolder by mutableStateOf( - stateBuilder.createInitialLoadingState( - initialCurrencyFrom = initialCurrencyFrom, - initialCurrencyTo = initialCurrencyTo, - fromNetworkInfo = initialCurrencyFrom.getNetworkInfo(), - ), - ) + var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState()) private set val feeSelectorRepository = FeeSelectorRepository() - // shows currency order (direct - swap initial to selected, reversed = selected to initial) - private val isOrderReversed = MutableStateFlow(value = params.isInitialReverseOrder) private val lastAmount = mutableStateOf(INITIAL_AMOUNT) private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO) - private val swapRouter: SwapRouter = SwapRouter(router = router) private var userCountry: UserCountry? = null - private var fromAccount: Account? = null - private var toAccount: Account? = null - private var fromAccountStatus: CryptoCurrencyStatus? = null - private var toAccountStatus: CryptoCurrencyStatus? = null - - /** - * If user came from Tangem Pay -> fromAccountCurrencyStatus == null - * If user didn't come from Tangem Pay -> fromAccountCurrencyStatus != null - * - * Remove when accounts are integrated into Tangem Pay - */ - private val canUseFromAccountCurrencyStatus = tangemPayInput == null - private val isUserResolvableError: (SwapState) -> Boolean = { swapState -> swapState is SwapState.SwapError && ( @@ -254,19 +223,13 @@ internal class SwapModel @Inject constructor( private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null - val currentScreen: SwapNavScreen - get() = swapRouter.currentScreen - val approvalSlotNavigation = SlotNavigation() - private val shouldUseGaslessApproval: Boolean = giveApprovalFeatureToggles.isGaslessApprovalEnabled val approvalCallback = object : GiveApprovalComponent.Callback { - override fun onApproveClick() { - sendPermissionApproveClickedEvent() - } + override fun onApproveClick() {} override fun onApproveDone() { - val fromContractAddress = dataState.fromCryptoCurrency?.currency?.getContractAddress() + val fromContractAddress = dataState.fromSwapCurrencyStatus?.currency?.getContractAddress() if (fromContractAddress != null) { allowPermissionsHandler.addAddressToInProgress(fromContractAddress) } @@ -284,37 +247,11 @@ internal class SwapModel @Inject constructor( override fun onCancelClick() { approvalSlotNavigation.dismiss() startLoadingQuotesFromLastState(isSilent = true) - analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked()) } } init { - chooseTokenBridge.searchQueryState - .onEach { query -> onSearchEntered(query.value) } - .launchIn(modelScope) - - chooseTokenBridge.onNewTokenAdded.receiveAsFlow() - .onEach { (addedToken, isSearched) -> - applyAddedToken(addedToken, isSearched.value) - } - .launchIn(modelScope) - - chooseTokenBridge.onTokenSelected.receiveAsFlow() - .onEach { result -> - onTokenSelect( - account = result.account, - cryptoCurrencyStatus = result.cryptoCurrencyStatus, - isSearched = result.isSearched, - ) - } - .launchIn(modelScope) - - chooseTokenBridge.onClose.receiveAsFlow() - .onEach { - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) - swapRouter.back() - } - .launchIn(modelScope) + subscribeToTokenSelection() modelScope.launch { val storyId = StoryContentIds.STORY_FIRST_TIME_SWAP.id @@ -332,72 +269,20 @@ internal class SwapModel @Inject constructor( userCountry = getUserCountryUseCase.invokeSync().getOrNull() ?: UserCountry.Other(Locale.getDefault().country) - modelScope.launch(dispatchers.io) { - if (canUseFromAccountCurrencyStatus) { - isAccountsMode = isAccountsModeEnabledUseCase.invokeSync() - - val fromAccountStatus = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWalletId, - currency = initialCurrencyFrom, - ).getOrNull() - val fromPaymentAccountStatus = - paymentAccountCryptoCurrencyStatusUseCase(userWalletId, initialCurrencyFrom).getOrNull() - val toAccountStatus = initialCurrencyTo?.let { currencyTo -> - getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWalletId, - currency = currencyTo, - ).getOrNull() - } - val toPaymentAccountStatus = initialCurrencyTo?.let { currencyTo -> - paymentAccountCryptoCurrencyStatusUseCase(userWalletId, currencyTo).getOrNull() - } - val fromAccount = fromAccountStatus?.account ?: fromPaymentAccountStatus?.first - val fromStatus = fromAccountStatus?.status ?: fromPaymentAccountStatus?.second - - if (fromAccount != null && fromStatus != null) { - this@SwapModel.fromAccount = fromAccount - this@SwapModel.fromAccountStatus = fromStatus - this@SwapModel.toAccount = toAccountStatus?.account ?: toPaymentAccountStatus?.first - this@SwapModel.toAccountStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second - this@SwapModel.initialFromStatus = fromStatus - this@SwapModel.initialToStatus = toAccountStatus?.status ?: toPaymentAccountStatus?.second - initTokens(isInitiallyReversed) - } else { - showAlert() - swapRouter.back() - } - } else { - val fromStatus = getFromStatus() - val toStatus = initialCurrencyTo?.let { currencyTo -> - singleAccountStatusListSupplier.getSyncOrNull(params.userWalletId) - .getCryptoCurrencyStatus(currencyTo) - .getOrNull() - } - - if (fromStatus == null) { - showAlert() - swapRouter.back() - } else { - initialFromStatus = fromStatus - initialToStatus = toStatus - initTokens(isInitiallyReversed) - } - } - } + initTokens() + // TODO swap analytics analyticsEventHandler.send( SwapEvents.SwapScreenOpened( - token = initialCurrencyFrom.symbol, - blockchain = initialCurrencyFrom.network.name, + token = initialCryptoCurrency?.symbol.orEmpty(), + blockchain = initialCryptoCurrency?.network?.name.orEmpty(), ), ) - getBalanceHidingSettingsUseCase() - .onEach { settings -> - isBalanceHidden = settings.isBalanceHidden - uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) - } - .launchIn(modelScope) + getBalanceHidingSettingsUseCase().onEach { settings -> + isBalanceHidden = settings.isBalanceHidden + uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) + }.launchIn(modelScope) } fun onStart() { @@ -413,220 +298,298 @@ internal class SwapModel @Inject constructor( super.onDestroy() } - private fun sendSelectTokenScreenOpenedEvent() { - val isAnyAvailableTokensTo = dataState.tokensDataState?.toGroup?.available?.isNotEmpty() == true - val isAnyAvailableTokensFrom = dataState.tokensDataState?.fromGroup?.available?.isNotEmpty() == true - val isAnyAvailableAccountTokensTo = !dataState.tokensDataState?.toGroup?.accountCurrencyList.isNullOrEmpty() - val isAnyAvailableAccountTokensFrom = !dataState.tokensDataState?.fromGroup?.accountCurrencyList.isNullOrEmpty() - val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom || - isAnyAvailableAccountTokensTo || isAnyAvailableAccountTokensFrom - analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens)) + private fun subscribeToTokenSelection() { + chooseFromTokenBridge.onCurrencyChosen.receiveAsFlow() + .onEach { result -> + onTokenSelect(result, isFromDirection = true) + } + .launchIn(modelScope) + + chooseFromTokenBridge.onClose.receiveAsFlow() + .onEach { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) + router.pop() + } + .launchIn(modelScope) + + chooseToTokenBridge.onCurrencyChosen.receiveAsFlow() + .onEach { result -> + onTokenSelect(result, isFromDirection = false) + } + .launchIn(modelScope) + + chooseToTokenBridge.onClose.receiveAsFlow() + .onEach { + analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false)) + router.pop() + } + .launchIn(modelScope) + } + + private fun initTokens() { + modelScope.launch(dispatchers.default) { + isAccountsMode = isAccountsModeEnabledUseCase.invokeSync() + + val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = initialCurrenciesResolver( + userWalletId = params.userWalletId, + initialCryptoCurrency = initialCryptoCurrency, + swapCurrencyPosition = params.currencyPosition, + isPaymentAccount = params.tangemPayInput != null, + ) + + dataState = dataState.copy( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + + selectWalletInSelector( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + filterTokensFromSelector() + + if (fromSwapCurrencyStatus != null) { + updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus) + subscribeToCoinBalanceUpdatesIfNeeded() + } + + uiState = stateBuilder.createInitialReadyState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, + ) + }, + ), + ), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + + // Check swap availability if there is pair + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null) { + initSwapPairs( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + } } @Suppress("LongMethod") - private fun initTokens(isReverseFromTo: Boolean) { - modelScope.launch(dispatchers.main) { - runCatching(dispatchers.io) { - swapInteractor.getTokensDataState(initialCurrencyFrom) - }.onSuccess { state -> - updateTokensState(state) + private suspend fun onTokenSelect(result: ChooseTokenResult, isFromDirection: Boolean) { + val selectedUserWallet = result.wallet + val selectedCurrencyStatus = result.currency + val selectedAccount = result.account.account - val (selectedCurrency, selectedAccount) = run { - var selectedAccountCurrency = toAccountStatus + val (fromSwapCurrencyStatus, toSwapCurrencyStatus) = if (isFromDirection) { + SwapCurrencyStatus( + userWallet = selectedUserWallet, + status = selectedCurrencyStatus, + account = selectedAccount, + ) to dataState.toSwapCurrencyStatus + } else { + dataState.fromSwapCurrencyStatus to SwapCurrencyStatus( + userWallet = selectedUserWallet, + status = selectedCurrencyStatus, + account = selectedAccount, + ) + } - if (selectedAccountCurrency == null) { - val amountSwapCurrency = swapInteractor.getInitialCurrencyToSwap( - initialCryptoCurrency = initialCurrencyFrom, - state = state, - isReverseFromTo = isReverseFromTo, + if (dataState.fromSwapCurrencyStatus != null) { + isAmountChangedByUser = true + } + + // Check whether pair was already selected + if (fromSwapCurrencyStatus == dataState.fromSwapCurrencyStatus && + toSwapCurrencyStatus == dataState.toSwapCurrencyStatus + ) { + startLoadingQuotesFromLastState(true) + return + } + + dataState = if (isFromDirection) { + // Reset amount if from token is changed + lastAmount.value = INITIAL_AMOUNT + lastReducedBalanceBy.value = BigDecimal.ZERO + SwapProcessDataState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } else { + dataState.copy( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + filterTokensFromSelector() + uiState = stateBuilder.updateCurrenciesState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, ) + }, + ), + ), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + shouldResetAmount = isFromDirection, + ) - if (amountSwapCurrency != null) { - selectedAccountCurrency = amountSwapCurrency.cryptoCurrencyStatus - } - } + router.pop() - selectedAccountCurrency to toAccount - } + if (isFromDirection && fromSwapCurrencyStatus != null) { + updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus) + } - val isApplied = applyInitialTokenChoice( - state = state, - selectedCurrency = selectedCurrency, - selectedAccount = selectedAccount, - isReverseFromTo = isReverseFromTo, + subscribeToCoinBalanceUpdatesIfNeeded() + + // Check swap availability if there is pair + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null) { + initSwapPairs( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } + } + + private fun onChangeCardsClicked() { + modelScope.launch { + singleTaskScheduler.cancelTask() + + val newFromSwapCurrencyStatus = dataState.toSwapCurrencyStatus + val newToSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + + isAmountChangedByUser = true + + lastAmount.value = INITIAL_AMOUNT + lastReducedBalanceBy.value = BigDecimal.ZERO + + dataState = SwapProcessDataState( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + pairs = dataState.pairs, + selectedPairProviders = dataState.selectedPairProviders, + ) + filterTokensFromSelector() + uiState = stateBuilder.updateCurrenciesState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, + ) + }, + ), + ), + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + shouldResetAmount = true, + ) + + if (newFromSwapCurrencyStatus != null && newToSwapCurrencyStatus != null) { + updateFeePaidCryptoCurrencyFor(newFromSwapCurrencyStatus) + val toProvidersList = swapInteractor.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + pairs = dataState.pairs, ) - - // assume that fromCryptoCurrency selected according reverse flag, - // so update fee paid currency according to it - val fromCryptoCurrency = dataState.fromCryptoCurrency - - if (isApplied && fromCryptoCurrency != null) { - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " + - "isReverseFromTo: $isReverseFromTo", + if (toProvidersList.isEmpty()) { + handleSwapNotSupported( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, ) - updateFeePaidCryptoCurrencyFor(fromCryptoCurrency) } else { - TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null") - } - - subscribeToCoinBalanceUpdatesIfNeeded() - }.onFailure { error -> - TangemLogger.e("Error", error) - - applyInitialTokenChoice( - state = TokensDataStateExpress.EMPTY, - selectedCurrency = null, - selectedAccount = null, - isReverseFromTo = isReverseFromTo, - ) - - uiState = stateBuilder.createInitialErrorState( - uiState, - (error as? ExpressException)?.expressDataError?.code ?: ExpressDataError.UnknownError.code, - ) { - uiState = stateBuilder.createInitialLoadingState( - initialCurrencyFrom = initialCurrencyFrom, - initialCurrencyTo = initialCurrencyTo, - fromNetworkInfo = initialCurrencyFrom.getNetworkInfo(), + startLoadingQuotes( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + toProvidersList = toProvidersList, ) - initTokens(isReverseFromTo) } } } } - private suspend fun applyAddedToken(addedToken: CryptoCurrency, isSearched: Boolean) { - analyticsEventHandler.send( - SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = addedToken.symbol), - ) - analyticsEventHandler.send( - SwapAnalyticsEvent.TokenSelected( - token = addedToken.symbol, - source = ScreensSources.Markets, - isSearched = isSearched, - ), - ) - val status = getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken) - // todo swap are sure?? about status.value is CryptoCurrencyStatus.Loaded - .firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded } - ?: return - val (selectedAccount, selectedCurrency) = status - - runCatching(dispatchers.io) { - swapInteractor.getTokensDataState(initialCurrencyFrom) - }.onSuccess { state -> - updateTokensState(state) - - applyInitialTokenChoice( - state = state, - selectedCurrency = selectedCurrency, - selectedAccount = selectedAccount, - isReverseFromTo = isOrderReversed.value, + private fun initSwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { + modelScope.launch { + swapInteractor.getPair( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + filterProviderTypes = if (tangemPayInput?.isWithdrawal == true) { + listOf(ExchangeProviderType.CEX) + } else { + ExchangeProviderType.getSwapProviderTypes() + }, + ).fold( + ifLeft = { error -> + handleSwapNotSupported( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + TangemLogger.e("Error getting swap pair", error) + }, + ifRight = { pairs -> + val providerList = swapInteractor.findProvidersForPairWithCheck( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + pairs = pairs, + ) + if (providerList.isEmpty()) { + handleSwapNotSupported( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } else { + dataState = dataState.copy( + pairs = pairs, + selectedPairProviders = providerList, + ) + startLoadingQuotes( + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + toProvidersList = providerList, + ) + } + }, ) - - subscribeToCoinBalanceUpdatesIfNeeded() - - swapRouter.back() - }.onFailure { error -> - TangemLogger.e("Error", error) } } + @Suppress("UnusedPrivateMember") private fun subscribeToCoinBalanceUpdatesIfNeeded() { - (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + if (fromSwapCurrencyStatus != null) { subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, + swapCurrencyStatus = fromSwapCurrencyStatus, isFromCurrency = true, ) } - (dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin -> + if (toSwapCurrencyStatus != null) { subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = coin, + swapCurrencyStatus = toSwapCurrencyStatus, isFromCurrency = false, ) } } - /** - * returns true if tokens are selected and dataState is updated, - * false if selected token is null and alert is shown with error message - */ - private fun applyInitialTokenChoice( - state: TokensDataStateExpress, - selectedCurrency: CryptoCurrencyStatus?, - selectedAccount: Account?, - isReverseFromTo: Boolean, - ): Boolean { - // exceptional case - if (selectedCurrency == null) { - TangemLogger.e("No available tokens to swap for ${initialCurrencyFrom.symbol}") - analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap()) - uiState = stateBuilder.createNoAvailableTokensToSwapState( - uiStateHolder = uiState, - fromToken = initialFromStatus, - ) - return false - } - isOrderReversed.value = isReverseFromTo - val (fromCurrencyStatus, toCurrencyStatus) = if (isOrderReversed.value) { - selectedCurrency to initialFromStatus - } else { - initialFromStatus to selectedCurrency - } - val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) { - if (isOrderReversed.value) { - selectedAccount to fromAccount - } else { - fromAccount to selectedAccount - } - } else { - null to null - } - dataState = dataState.copy( - fromCryptoCurrency = fromCurrencyStatus, - fromAccount = fromAccount, - toCryptoCurrency = toCurrencyStatus, - toAccount = toAccount, - tokensDataState = state, - ) - - if (handleSwapNotSupported( - state = state, - fromToken = fromCurrencyStatus, - toToken = toCurrencyStatus, - fromAccount = fromAccount, - toAccount = toAccount, - ) - ) { - return true - } - - startLoadingQuotes( - fromToken = fromCurrencyStatus, - fromAccount = fromAccount, - toToken = toCurrencyStatus, - toAccount = toAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus), - ) - return true - } - - private fun updateTokensState(tokenDataState: TokensDataStateExpress) { - val tokensDataState = if (isOrderReversed.value) tokenDataState.fromGroup else tokenDataState.toGroup - chooseTokenBridge.updateCurrenciesGroup(tokensDataState) - } - private fun startLoadingQuotes( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -634,24 +597,20 @@ internal class SwapModel @Inject constructor( updateFeeBlock: Boolean = true, ) { singleTaskScheduler.cancelTask() + if (amount.isBlank()) return if (!isSilent) { uiState = stateBuilder.createQuotesLoadingState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, uiStateHolder = uiState, - fromToken = fromToken.currency, - toToken = toToken.currency, - fromAccount = fromAccount, - toAccount = toAccount, - mainTokenId = initialCurrencyFrom.id.value, ) feeSelectorRepository.state.value = FeeSelectorUM.Loading } singleTaskScheduler.scheduleTask( modelScope, loadQuotesTask( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, reduceBalanceBy = reduceBalanceBy, toProvidersList = toProvidersList, @@ -661,45 +620,45 @@ internal class SwapModel @Inject constructor( } private fun startLoadingQuotesFromLastState(isSilent: Boolean = false, updateFeeBlock: Boolean = true) { - val fromCurrency = dataState.fromCryptoCurrency - val toCurrency = dataState.toCryptoCurrency + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus val amount = dataState.amount - if (fromCurrency != null && toCurrency != null && amount != null) { + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null && amount != null) { startLoadingQuotes( - fromToken = fromCurrency, - fromAccount = dataState.fromAccount, - toToken = toCurrency, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = amount, isSilent = isSilent, reduceBalanceBy = dataState.reduceBalanceBy, - toProvidersList = findSwapProviders(fromCurrency, toCurrency), + toProvidersList = dataState.selectedPairProviders, updateFeeBlock = updateFeeBlock, ) } } - private suspend fun updateFeePaidCryptoCurrencyFor(fromToken: CryptoCurrencyStatus) { - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = fromToken, - ) - .onLeft { TangemLogger.e("Unable to get fee paid crypto currency status for ${fromToken.currency.id}") } - .onRight { currencyStatus -> - if (currencyStatus == null) { - TangemLogger.e("Fee paid crypto currency status is null for ${fromToken.currency.id}") - } + private suspend fun updateFeePaidCryptoCurrencyFor(fromSwapCurrencyStatus: SwapCurrencyStatus) { + val fromCryptoCurrency = fromSwapCurrencyStatus.currency + val feePaidCryptoCurrency = if (fromSwapCurrencyStatus.account is Account.Payment) { + fromSwapCurrencyStatus.status + } else { + getFeePaidCryptoCurrencyStatusSyncUseCase( + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, + ).onLeft { + TangemLogger.e("Unable to get fee paid crypto currency status for ${fromCryptoCurrency.id}") + }.onRight { currencyStatus -> + if (currencyStatus == null) { + TangemLogger.e("Fee paid crypto currency status is null for ${fromCryptoCurrency.id}") } - .getOrNull(), - ) + }.getOrNull() + } + + dataState = dataState.copy(feePaidCryptoCurrency = feePaidCryptoCurrency) } private fun loadQuotesTask( - fromToken: CryptoCurrencyStatus, - fromAccount: Account?, - toToken: CryptoCurrencyStatus, - toAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, amount: String, reduceBalanceBy: BigDecimal, toProvidersList: List, @@ -715,13 +674,10 @@ internal class SwapModel @Inject constructor( amount = amount, reduceBalanceBy = reduceBalanceBy, swapDataModel = null, - approveDataModel = null, ) swapInteractor.findBestQuote( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, providers = toProvidersList, amountToSwap = amount, reduceBalanceBy = reduceBalanceBy, @@ -732,7 +688,12 @@ internal class SwapModel @Inject constructor( onSuccess = { providersState -> if (providersState.isNotEmpty()) { val (provider, state) = updateLoadedQuotes(providersState) - setupLoadedState(provider = provider, state = state, fromToken = fromToken, toToken = toToken) + setupLoadedState( + provider = provider, + state = state, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) val successStates = providersState.getLastLoadedSuccessStates() val pricesLowerBest = getPricesLowerBest(provider.providerId, successStates) uiState = stateBuilder.updateProvidersBottomSheetContent( @@ -747,7 +708,8 @@ internal class SwapModel @Inject constructor( shouldUpdateFeeBlock = true } } else { - feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) + feeSelectorRepository.state.value = + FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) TangemLogger.e("Accidentally empty quotes list") } }, @@ -762,17 +724,17 @@ internal class SwapModel @Inject constructor( private fun setupLoadedState( provider: SwapProvider, state: SwapState, - fromToken: CryptoCurrencyStatus, - toToken: CryptoCurrencyStatus?, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, ) { when (state) { is SwapState.QuotesLoadedState -> { - setupQuotesLoadedUiState(provider, state, fromToken) - sendAnalyticsForNotifications(provider, fromToken, toToken) + setupQuotesLoadedUiState(provider, state) + sendAnalyticsForNotifications(provider, fromSwapCurrencyStatus.status, toSwapCurrencyStatus.status) updatePermissionNotificationState(state) } is SwapState.EmptyAmountState -> { - setupEmptyAmountUiState(state, fromToken) + setupEmptyAmountUiState(state, fromSwapCurrencyStatus) lastPermissionNotificationTokens = null } is SwapState.SwapError -> { @@ -782,24 +744,18 @@ internal class SwapModel @Inject constructor( } } - private fun setupQuotesLoadedUiState( - provider: SwapProvider, - state: SwapState.QuotesLoadedState, - fromToken: CryptoCurrencyStatus, - ) { - fillLoadedDataState(state, state.permissionState, state.swapDataModel) + private fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) { + fillLoadedDataState(state.permissionState, state.swapDataModel) val loadedStates = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates() val bestRatedProviderId = findBestQuoteProvider(loadedStates)?.providerId ?: provider.providerId uiState = stateBuilder.createQuotesLoadedState( uiStateHolder = uiState, quoteModel = state, - fromToken = fromToken.currency, feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, swapProvider = provider, bestRatedProviderId = bestRatedProviderId, isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1, selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, - isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), hideFee = isTangemPayWithdrawal(), ) @@ -813,7 +769,7 @@ internal class SwapModel @Inject constructor( if (uiState.notifications.any { it is SwapNotificationUM.Error.UnableToCoverFeeWarning }) { analyticsEventHandler.send( SwapEvents.NoticeNotEnoughFee( - token = initialCurrencyFrom.symbol, + token = fromToken.currency.symbol, blockchain = fromToken.currency.network.name, ), ) @@ -843,10 +799,10 @@ internal class SwapModel @Inject constructor( } private fun updatePermissionNotificationState(state: SwapState.QuotesLoadedState) { - val fromTokenId = state.fromTokenInfo.cryptoCurrencyStatus - .currency.id.value - val toTokenId = state.toTokenInfo.cryptoCurrencyStatus - .currency.id.value + val fromCryptoCurrencyStatus = state.fromTokenInfo.swapCurrencyStatus + val toCryptoCurrencyStatus = state.toTokenInfo.swapCurrencyStatus + val fromTokenId = fromCryptoCurrencyStatus.currency.id.value + val toTokenId = toCryptoCurrencyStatus.currency.id.value val currentTokenPair = Pair(fromTokenId, toTokenId) when { @@ -860,15 +816,14 @@ internal class SwapModel @Inject constructor( } } - private fun setupEmptyAmountUiState(state: SwapState.EmptyAmountState, fromToken: CryptoCurrencyStatus) { - val toTokenStatus = dataState.toCryptoCurrency + private fun setupEmptyAmountUiState( + state: SwapState.EmptyAmountState, + fromSwapCurrencyStatus: SwapCurrencyStatus, + ) { uiState = stateBuilder.createQuotesEmptyAmountState( uiStateHolder = uiState, emptyAmountState = state, - fromTokenStatus = fromToken, - toTokenStatus = toTokenStatus, - isReverseSwapPossible = isReverseSwapPossible(), - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, ) } @@ -878,24 +833,25 @@ internal class SwapModel @Inject constructor( uiStateHolder = uiState, swapProvider = provider, fromToken = state.fromTokenInfo, - toToken = dataState.toCryptoCurrency, + toSwapCurrencyStatus = dataState.toSwapCurrencyStatus, expressDataError = state.error, includeFeeInAmount = state.includeFeeInAmount, - isReverseSwapPossible = isReverseSwapPossible(), needApplyFCARestrictions = userCountry.needApplyFCARestrictions(), - toAccount = dataState.toAccount, ) sendErrorAnalyticsEvent(state.error, provider) } private fun sendErrorAnalyticsEvent(error: ExpressDataError, provider: SwapProvider) { - val receiveToken = dataState.toCryptoCurrency?.currency?.let { currency -> + val fromCryptoCurrency = dataState.fromSwapCurrencyStatus?.currency?.let { currency -> + "${currency.network.rawId}:${currency.symbol}" + } + val toCryptoCurrency = dataState.toSwapCurrencyStatus?.currency?.let { currency -> "${currency.network.rawId}:${currency.symbol}" } analyticsErrorEventHandler.sendErrorEvent( SwapEvents.NoticeProviderError( - sendToken = "${initialCurrencyFrom.network.rawId}:${initialCurrencyFrom.symbol}", - receiveToken = receiveToken.orEmpty(), + sendToken = fromCryptoCurrency.orEmpty(), + receiveToken = toCryptoCurrency.orEmpty(), provider = provider, errorCode = error.code, errorMessage = error.message, @@ -951,38 +907,16 @@ internal class SwapModel @Inject constructor( } } - private fun fillLoadedDataState( - state: SwapState.QuotesLoadedState, - permissionState: PermissionDataState, - swapDataModel: SwapDataModel?, - ) { - dataState = if (permissionState is PermissionDataState.PermissionReadyForRequest) { - dataState.copy(approveDataModel = permissionState.requestApproveData) + private fun fillLoadedDataState(permissionState: PermissionDataState, swapDataModel: SwapDataModel?) { + dataState = if (permissionState is PermissionDataState.PermissionRequired) { + dataState.copy() } else { dataState.copy( swapDataModel = swapDataModel, - selectedFee = updateOrSelectFee(state), ) } } - private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee.Legacy? { - val selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL - return when (val txFee = state.txFee) { - TxFeeState.Empty -> null - is TxFeeState.MultipleFeeState -> { - if (selectedFeeType == FeeType.NORMAL) { - txFee.normalFee - } else { - txFee.priorityFee - } - } - is TxFeeState.SingleFeeState -> { - txFee.fee - } - } - } - @Suppress("LongMethod") private fun onSwapClick() { singleTaskScheduler.cancelTask() @@ -993,7 +927,8 @@ internal class SwapModel @Inject constructor( TangemLogger.e("Last loaded quotes state is null") return } - val fromCurrency = requireNotNull(dataState.fromCryptoCurrency) + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val fee = getSelectedFee() val isTangemPayWithdrawal = isTangemPayWithdrawal() @@ -1009,13 +944,11 @@ internal class SwapModel @Inject constructor( modelScope.launch(dispatchers.main) { runCatching(dispatchers.io) { swapInteractor.onSwap( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, swapProvider = provider, swapData = dataState.swapDataModel, - currencyToSend = fromCurrency, - currencyToGet = requireNotNull(dataState.toCryptoCurrency), amountToSwap = requireNotNull(dataState.amount), - fromAccount = dataState.fromAccount, - toAccount = dataState.toAccount, includeFeeInAmount = lastLoadedQuotesState.preparedSwapConfigState.includeFeeInAmount, fee = fee, expressOperationType = ExpressOperationType.SWAP, @@ -1030,12 +963,12 @@ internal class SwapModel @Inject constructor( return@onSuccess } sendSuccessSwapEvent( - fromCurrency.currency, + fromSwapCurrencyStatus.currency, (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL, ) val url = getExplorerTransactionUrlUseCase( txHash = swapTransactionState.txHash, - currency = fromCurrency.currency, + currency = fromSwapCurrencyStatus.currency, ).getOrElse { TangemLogger.i("tx hash explore not supported") "" @@ -1052,7 +985,7 @@ internal class SwapModel @Inject constructor( urlOpener.openUrl(url) } analyticsEventHandler.send( - event = SwapEvents.ButtonExplore(initialCurrencyFrom.symbol), + event = SwapEvents.ButtonExplore(fromSwapCurrencyStatus.currency.symbol), ) }, onStatusClick = { @@ -1060,14 +993,14 @@ internal class SwapModel @Inject constructor( if (!txExternalUrl.isNullOrBlank()) { urlOpener.openUrl(txExternalUrl) analyticsEventHandler.send( - event = SwapEvents.ButtonStatus(initialCurrencyFrom.symbol), + event = SwapEvents.ButtonStatus(fromSwapCurrencyStatus.currency.symbol), ) } }, ) sendSuccessEvent() - swapRouter.openScreen(SwapNavScreen.Success) + router.replaceAll(SwapRoute.Success) } SwapTransactionState.DemoMode -> { showDemoModeAlert() @@ -1077,7 +1010,10 @@ internal class SwapModel @Inject constructor( showTransactionErrorAlert(swapTransactionState) } is SwapTransactionState.TangemPayWithdrawalData -> { - processTangemPayWithdrawal(swapTransactionState = swapTransactionState) + processTangemPayWithdrawal( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + swapTransactionState = swapTransactionState, + ) } } }.onFailure { error -> @@ -1088,9 +1024,12 @@ internal class SwapModel @Inject constructor( } } - private suspend fun processTangemPayWithdrawal(swapTransactionState: SwapTransactionState.TangemPayWithdrawalData) { + private suspend fun processTangemPayWithdrawal( + fromSwapCurrencyStatus: SwapCurrencyStatus, + swapTransactionState: SwapTransactionState.TangemPayWithdrawalData, + ) { tangemPayWithdrawUseCase( - userWallet = userWallet, + userWallet = fromSwapCurrencyStatus.userWallet, cryptoAmount = swapTransactionState.cryptoAmount, cryptoCurrencyId = swapTransactionState.cryptoCurrencyId, receiverCexAddress = swapTransactionState.cexAddress, @@ -1108,10 +1047,8 @@ internal class SwapModel @Inject constructor( WithdrawalResult.Success -> { val txUrl = swapTransactionState.storeData.txExternalUrl swapInteractor.storeSwapTransaction( - currencyToSend = swapTransactionState.storeData.currencyToSend, - currencyToGet = swapTransactionState.storeData.currencyToGet, - fromAccount = swapTransactionState.storeData.fromAccount, - toAccount = swapTransactionState.storeData.toAccount, + fromSwapCurrencyStatus = swapTransactionState.storeData.fromSwapCurrencyStatus, + toSwapCurrencyStatus = swapTransactionState.storeData.toSwapCurrencyStatus, amount = swapTransactionState.storeData.amount, swapProvider = swapTransactionState.storeData.swapProvider, swapDataModel = swapTransactionState.storeData.swapDataModel, @@ -1127,7 +1064,7 @@ internal class SwapModel @Inject constructor( txUrl = txUrl.orEmpty(), onExploreClick = { if (txUrl != null) urlOpener.openUrl(txUrl) }, ) - swapRouter.openScreen(SwapNavScreen.Success) + router.replaceAll(SwapRoute.Success) } } } @@ -1136,19 +1073,19 @@ internal class SwapModel @Inject constructor( private suspend fun sendSuccessEvent() { val provider = dataState.selectedProvider ?: return val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL - val fromCurrency = dataState.fromCryptoCurrency?.currency ?: return - val toCurrency = dataState.toCryptoCurrency?.currency ?: return - val fromDerivationIndex = dataState.fromAccount?.derivationIndex?.value - val toDerivationIndex = dataState.toAccount?.derivationIndex?.value + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus ?: return + val fromDerivationIndex = fromSwapCurrencyStatus.account.derivationIndex?.value + val toDerivationIndex = toSwapCurrencyStatus.account.derivationIndex?.value analyticsEventHandler.send( SwapEvents.SwapInProgressScreen( provider = provider, commission = fee, - sendBlockchain = fromCurrency.network.name, - receiveBlockchain = toCurrency.network.name, - sendToken = fromCurrency.symbol, - receiveToken = toCurrency.symbol, + sendBlockchain = fromSwapCurrencyStatus.currency.network.name, + receiveBlockchain = toSwapCurrencyStatus.currency.network.name, + sendToken = fromSwapCurrencyStatus.currency.symbol, + receiveToken = toSwapCurrencyStatus.currency.symbol, feeToken = getFeeToken().symbol, fromDerivationIndex = fromDerivationIndex, toDerivationIndex = toDerivationIndex, @@ -1157,290 +1094,50 @@ internal class SwapModel @Inject constructor( ) } - @Suppress("LongMethod") - private fun givePermissionsToSwap() { - modelScope.launch(dispatchers.main) { - runSuspendCatching { - val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) { - "dataState.fromCryptoCurrency might not be null" - } - val fromToken = fromCryptoCurrency.currency + private fun subscribeToCoinBalanceUpdates(swapCurrencyStatus: SwapCurrencyStatus, isFromCurrency: Boolean) { + val swapCurrency = swapCurrencyStatus.currency - val approveDataModel = requireNotNull(dataState.approveDataModel) { - "dataState.approveDataModel.spenderAddress shouldn't be null" - } - val approveType = - requireNotNull(uiState.permissionState.getApproveTypeOrNull()?.toDomainApproveType()) { - "uiState.permissionState should not be null" - } - val feeForPermission = when (val fee = approveDataModel.fee) { - TxFeeState.Empty -> { - showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) - TangemLogger.e("Fee should not be Empty") - return@launch - } - is TxFeeState.MultipleFeeState -> fee.priorityFee - is TxFeeState.SingleFeeState -> fee.fee - } - runCatching(dispatchers.io) { - swapInteractor.givePermissionToSwap( - networkId = fromToken.network.rawId, - permissionOptions = PermissionOptions( - approveData = approveDataModel, - forTokenContractAddress = (fromToken as? CryptoCurrency.Token)?.contractAddress.orEmpty(), - fromTokenStatus = fromCryptoCurrency, - approveType = approveType, - txFee = feeForPermission, - spenderAddress = requireNotNull(dataState.approveDataModel).spenderAddress, - ), - ) - }.onSuccess { swapTransactionState -> - when (swapTransactionState) { - is SwapTransactionState.TxSent -> { - // TODO [REDACTED_TASK_KEY] gasless analytics - sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType) - updateWalletBalance() - uiState = stateBuilder.loadingPermissionState(uiState) - uiState = stateBuilder.dismissBottomSheet(uiState) - startLoadingQuotesFromLastState(isSilent = true) - } - is SwapTransactionState.Error -> { - showTransactionErrorAlert(swapTransactionState) - } - SwapTransactionState.DemoMode -> { - showDemoModeAlert() - } - is SwapTransactionState.TangemPayWithdrawalData -> { - processTangemPayWithdrawal(swapTransactionState = swapTransactionState) - } - } - }.onFailure { showAlert() } - }.onFailure { error -> - TangemLogger.e(error.message.orEmpty()) - showAlert() - } - } - } + when (swapCurrencyStatus.account) { + is Account.CryptoPortfolio -> getAccountCurrencyStatusUseCase( + userWalletId = swapCurrencyStatus.userWalletId, + currency = swapCurrency, + ).map { (_, status) -> status } + is Account.Payment -> getPaymentAccountCryptoCurrencyStatusUseCase( + userWalletId = swapCurrencyStatus.userWalletId, + cryptoCurrency = swapCurrency, + ).map { (_, status) -> status } + }.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes + .onEach { currencyStatus -> - private fun onSearchEntered(searchQuery: String) { - val tokenDataState = dataState.tokensDataState ?: return - val group = if (isOrderReversed.value) { - tokenDataState.fromGroup - } else { - tokenDataState.toGroup - } - - val available = group.available.filter { swapAvailability -> - swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || - swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) - } - val unavailable = group.unavailable.filter { swapAvailability -> - swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) || - swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true) - } - val accountCurrencyList = group.accountCurrencyList.mapNotNull { accountSwapAvailability -> - val filteredCurrencies = accountSwapAvailability.currencyList.filter { accountSwapCurrency -> - val currency = accountSwapCurrency.cryptoCurrencyStatus.currency - currency.name.contains(searchQuery, ignoreCase = true) || - currency.symbol.contains(searchQuery, ignoreCase = true) - } - - if (filteredCurrencies.isEmpty()) { - return@mapNotNull null - } - - accountSwapAvailability.copy( - currencyList = filteredCurrencies, - ) - } - - val filteredTokenDataState = if (isOrderReversed.value) { - tokenDataState.copy( - fromGroup = tokenDataState.fromGroup.copy( - available = available, - unavailable = unavailable, - accountCurrencyList = accountCurrencyList, - isAfterSearch = true, - ), - ) - } else { - tokenDataState.copy( - toGroup = tokenDataState.toGroup.copy( - available = available, - unavailable = unavailable, - accountCurrencyList = accountCurrencyList, - isAfterSearch = true, - ), - ) - } - updateTokensState(filteredTokenDataState) - } - - @Suppress("LongMethod") - private fun onTokenSelect(account: Account, cryptoCurrencyStatus: CryptoCurrencyStatus, isSearched: Boolean) { - val tokens = dataState.tokensDataState ?: return - val foundToken = cryptoCurrencyStatus - val foundAccount = account - - foundToken.currency.symbol.let { symbol -> - analyticsEventHandler.send( - SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = symbol), - ) - - analyticsEventHandler.send( - SwapAnalyticsEvent.TokenSelected( - token = symbol, - source = ScreensSources.Portfolio, - isSearched = isSearched, - ), - ) - } - - val fromToken: CryptoCurrencyStatus - val fromAccount: Account? - val toToken: CryptoCurrencyStatus - val toAccount: Account? - if (isOrderReversed.value) { - fromToken = foundToken - fromAccount = foundAccount - toToken = initialFromStatus - toAccount = this.fromAccount - - val newToken = fromToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = true, - ) - } else { - fromTokenBalanceJobHolder.cancel() - } - } else { - fromToken = initialFromStatus - fromAccount = this.fromAccount - toToken = foundToken - toAccount = foundAccount - - val newToken = toToken.currency as? CryptoCurrency.Coin - if (newToken != null) { - subscribeToCoinBalanceUpdates( - userWalletId = userWalletId, - coin = newToken, - isFromCurrency = false, - ) - } else { - toTokenBalanceJobHolder.cancel() - } - } - - if (dataState.fromCryptoCurrency != null && dataState.tokensDataState != null) { - isAmountChangedByUser = true - } - - dataState = dataState.copy( - fromCryptoCurrency = fromToken, - fromAccount = fromAccount, - toCryptoCurrency = toToken, - toAccount = toAccount, - selectedProvider = null, - ) - swapRouter.openScreen(SwapNavScreen.Main) - if (handleSwapNotSupported( - state = tokens, - fromToken = fromToken, - toToken = toToken, - fromAccount = fromAccount, - toAccount = toAccount, - ) - ) { - return - } - modelScope.launch { - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " + - "isOrderReversed: ${isOrderReversed.value}", - ) - if ((uiState.sendCardData as? SwapCardState.SwapCardData)?.type is TransactionCardType.ReadOnly) { - uiState = stateBuilder.createInitialLoadingState( - initialCurrencyFrom = fromToken.currency, - initialCurrencyTo = toToken.currency, - fromNetworkInfo = fromToken.currency.getNetworkInfo(), - ) - } - updateFeePaidCryptoCurrencyFor(fromToken) - startLoadingQuotes( - fromToken = fromToken, - fromAccount = fromAccount, - toToken = toToken, - toAccount = toAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromToken, toToken), - ) - } - updateTokensState(tokens) - } - - @Suppress("LongMethod", "CyclomaticComplexMethod") - private fun subscribeToCoinBalanceUpdates( - userWalletId: UserWalletId, - coin: CryptoCurrency.Coin, - isFromCurrency: Boolean, - ) { - TangemLogger.d("Subscribe to ${coin.id} balance updates") - - getAccountCurrencyStatusUseCase( - userWalletId = userWalletId, - currency = coin, - ).distinctUntilChanged { old, new -> old.status.value.amount == new.status.value.amount } // Check only balance changes - .onEach { (account, currencyStatus) -> - TangemLogger.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}") - - if (isFromCurrency) { - dataState = dataState.copy( - feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase( - userWalletId = userWalletId, - cryptoCurrencyStatus = currencyStatus, + when { + isFromCurrency && currencyStatus.currency.id == swapCurrency.id -> { + dataState = dataState.copy( + fromSwapCurrencyStatus = swapCurrencyStatus.copy(status = currencyStatus), ) - .onLeft { - TangemLogger.e( - "Coin balance: Unable to get fee paid crypto currency status for " + - "${currencyStatus.currency.id}", + } + !isFromCurrency && currencyStatus.currency.id == swapCurrency.id -> { + dataState = dataState.copy( + toSwapCurrencyStatus = swapCurrencyStatus.copy(status = currencyStatus), + ) + } + else -> Unit + } + + uiState = stateBuilder.updateCurrencyBalanceStatus( + uiState = uiState, + fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus, + toSwapCurrencyStatus = dataState.toSwapCurrencyStatus, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, ) - } - .onRight { status -> - if (status == null) { - TangemLogger.e( - "Coin balance: Fee paid crypto currency status is null " + - "for ${currencyStatus.currency.id}", - ) - } - } - .getOrNull() - ?: currencyStatus, - ) - } - - uiState = when { - isFromCurrency && currencyStatus.currency.id == dataState.fromCryptoCurrency?.currency?.id -> { - dataState = dataState.copy( - fromCryptoCurrency = currencyStatus, - fromAccount = account, - ) - stateBuilder.updateSendCurrencyBalance(uiState, currencyStatus) - } - !isFromCurrency && currencyStatus.currency.id == dataState.toCryptoCurrency?.currency?.id -> { - dataState = dataState.copy( - toCryptoCurrency = currencyStatus, - toAccount = account, - ) - stateBuilder.updateReceiveCurrencyBalance(uiState, currencyStatus) - } - else -> { - uiState - } - } + }, + ), + ), + ) startLoadingQuotesFromLastState(isSilent = true) } .flowOn(dispatchers.main) @@ -1448,74 +1145,20 @@ internal class SwapModel @Inject constructor( .saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder) } - private fun onChangeCardsClicked() { - modelScope.launch { - val newFromToken = dataState.toCryptoCurrency - val newFromAccount = dataState.toAccount - val newToToken = dataState.fromCryptoCurrency - val newToAccount = dataState.fromAccount - - if (newFromToken != null && newToToken != null) { - isAmountChangedByUser = true - - dataState = dataState.copy( - fromCryptoCurrency = newFromToken, - fromAccount = newFromAccount, - toCryptoCurrency = newToToken, - toAccount = newToAccount, - ) - isOrderReversed.value = !isOrderReversed.value - TangemLogger.i( - "updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " + - "isOrderReversed: ${isOrderReversed.value}", - ) - updateFeePaidCryptoCurrencyFor(newFromToken) - dataState.tokensDataState?.let { tokensDataState -> - updateTokensState(tokensDataState) - } - - val minTxAmount = getMinimumTransactionAmountSyncUseCase( - userWalletId, - newFromToken, - ).getOrNull() - val decimals = newFromToken.currency.decimals - lastAmount.value = cutAmountWithDecimals(decimals, lastAmount.value) - lastReducedBalanceBy.value = BigDecimal.ZERO - uiState = stateBuilder.updateSwapAmount( - uiState = uiState, - amountFormatted = inputNumberFormatter.formatWithThousands(lastAmount.value, decimals), - amountRaw = lastAmount.value, - fromToken = newFromToken.currency, - minTxAmount = minTxAmount, - fromAccount = dataState.fromAccount, - ) - startLoadingQuotes( - fromToken = newFromToken, - fromAccount = newFromAccount, - toToken = newToToken, - toAccount = newToAccount, - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(newFromToken, newToToken), - ) - } - } - } - private fun onAmountChanged( value: String, forceQuotesUpdate: Boolean = false, reduceBalanceBy: BigDecimal = BigDecimal.ZERO, ) { modelScope.launch { - val fromToken = dataState.fromCryptoCurrency - val toToken = dataState.toCryptoCurrency - if (fromToken != null) { - val decimals = fromToken.currency.decimals + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + if (fromSwapCurrencyStatus != null) { + val decimals = fromSwapCurrencyStatus.currency.decimals val cutValue = cutAmountWithDecimals(decimals, value) val minTxAmount = getMinimumTransactionAmountSyncUseCase( - userWalletId, - fromToken, + userWalletId = fromSwapCurrencyStatus.userWalletId, + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).getOrNull() lastAmount.value = cutValue lastReducedBalanceBy.value = reduceBalanceBy @@ -1523,25 +1166,22 @@ internal class SwapModel @Inject constructor( uiState = uiState, amountFormatted = inputNumberFormatter.formatWithThousands(cutValue, decimals), amountRaw = lastAmount.value, - fromToken = fromToken.currency, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, minTxAmount = minTxAmount, - fromAccount = dataState.fromAccount, ) - if (toToken != null) { - if (toToken.value.amount != null) { + if (toSwapCurrencyStatus != null) { + if (toSwapCurrencyStatus.status.value.amount != null) { isAmountChangedByUser = true } amountDebouncer.debounce(modelScope, DEBOUNCE_AMOUNT_DELAY, forceUpdate = forceQuotesUpdate) { startLoadingQuotes( - fromToken = fromToken, - fromAccount = dataState.fromAccount, - toToken = toToken, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, - toProvidersList = findSwapProviders(fromToken, toToken), + toProvidersList = dataState.selectedPairProviders, ) } } @@ -1550,8 +1190,8 @@ internal class SwapModel @Inject constructor( } private fun onMaxAmountClicked() { - dataState.fromCryptoCurrency?.let { fromCurrency -> - val balance = swapInteractor.getTokenBalance(fromCurrency) + dataState.fromSwapCurrencyStatus?.let { fromCurrency -> + val balance = swapInteractor.getTokenBalance(fromCurrency.status) onAmountChanged(balance.formatToUIRepresentation()) } } @@ -1603,9 +1243,11 @@ internal class SwapModel @Inject constructor( } private fun onTangemPaySupportClick(txId: String) { + val fromUserWalletId = dataState.fromSwapCurrencyStatus?.userWalletId ?: return + modelScope.launch { - val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch - val customerId = getTangemPayCustomerIdUseCase(userWallet.walletId).getOrNull().orEmpty() + val metaInfo = getWalletMetaInfoUseCase(fromUserWalletId).getOrNull() ?: return@launch + val customerId = getTangemPayCustomerIdUseCase(fromUserWalletId).getOrNull().orEmpty() val email = FeedbackEmailType.Visa.Withdrawal( walletMetaInfo = metaInfo, customerId = customerId, @@ -1671,8 +1313,8 @@ internal class SwapModel @Inject constructor( onAmountChanged = { onAmountChanged(it) }, onSwapClick = { onSwapClick() - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol + val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol + val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol if (sendTokenSymbol != null && receiveTokenSymbol != null) { analyticsEventHandler.send( SwapEvents.ButtonSwapClicked( @@ -1682,10 +1324,6 @@ internal class SwapModel @Inject constructor( ) } }, - onGivePermissionClick = { - givePermissionsToSwap() - sendPermissionApproveClickedEvent() - }, onChangeCardsClicked = { onChangeCardsClicked() analyticsEventHandler.send(SwapEvents.ButtonSwipeClicked()) @@ -1695,9 +1333,8 @@ internal class SwapModel @Inject constructor( if (bottomSheet != null && bottomSheet.isShown) { uiState = stateBuilder.dismissBottomSheet(uiState) } else { - swapRouter.back() + router.pop() } - onSearchEntered("") }, onMaxAmountSelected = ::onMaxAmountClicked, onReduceToAmount = ::onReduceAmountClicked, @@ -1705,20 +1342,9 @@ internal class SwapModel @Inject constructor( openPermissionBottomSheet = { singleTaskScheduler.cancelTask() sendGivePermissionClickedEvent() - if (shouldUseGaslessApproval) { - approvalSlotNavigation.activate(Unit) - } else { - uiState = stateBuilder.showPermissionBottomSheet(uiState) { - startLoadingQuotesFromLastState(isSilent = true) - analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked()) - uiState = stateBuilder.dismissBottomSheet(uiState) - } - } + approvalSlotNavigation.activate(Unit) }, onAmountSelected = { onAmountSelected(it) }, - onChangeApproveType = { approveType -> - uiState = stateBuilder.updateApproveType(uiState, approveType) - }, onClickFee = { val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val txFeeState = @@ -1753,9 +1379,10 @@ internal class SwapModel @Inject constructor( onProviderSelect = { providerId -> val provider = findAndSelectProvider(providerId) val swapState = dataState.lastLoadedSwapStates[provider] - val fromToken = dataState.fromCryptoCurrency - val toToken = dataState.toCryptoCurrency - if (provider != null && swapState != null && fromToken != null) { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + val isNotNullCurrency = fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null + if (provider != null && swapState != null && isNotNullCurrency) { modelScope.launch { feeSelectorRepository.state.value = FeeSelectorUM.Loading feeSelectorReloadTrigger.triggerUpdate() @@ -1765,40 +1392,78 @@ internal class SwapModel @Inject constructor( setupLoadedState( provider = provider, state = swapState, - fromToken = fromToken, - toToken = toToken, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, ) } }, - onBuyClick = { currency -> - swapRouter.openTokenDetails( - userWalletId = userWalletId, - currency = currency, + onBuyClick = { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@UiActions + val feePaidCryptoCurrency = dataState.feePaidCryptoCurrency ?: return@UiActions + val route = AppRoute.CurrencyDetails( + userWalletId = fromSwapCurrencyStatus.userWalletId, + currency = feePaidCryptoCurrency.currency, ) + + appRouter.push(route) }, onRetryClick = { startLoadingQuotesFromLastState() }, onReceiveCardWarningClick = { val selectedProvider = dataState.selectedProvider ?: return@UiActions - val currencySymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return@UiActions + val currencySymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return@UiActions val isPriceImpact = uiState.priceImpact.type != PriceImpact.Type.NONE showSwapInfoAlert(isPriceImpact, currencySymbol, selectedProvider) }, onLinkClick = urlOpener::openUrl, - onSelectTokenClick = { - swapRouter.openScreen(SwapNavScreen.SelectToken) - sendSelectTokenScreenOpenedEvent() + onSelectTokenClick = { direction -> + singleTaskScheduler.cancelTask() // Need to stop auto quotes fetching + router.push( + SwapRoute.SelectToken(isFromDirection = direction == TokenSelectionDirection.FROM), + ) }, onSuccess = { - swapRouter.openScreen(SwapNavScreen.Success) - }, - onOpenLearnMoreAboutApproveClick = { - urlOpener.openUrl(RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP) + router.replaceAll(SwapRoute.Success) }, ) } + private fun selectWalletInSelector( + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, + ) { + if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus == null) { + chooseFromTokenBridge.selectWalletTab(fromSwapCurrencyStatus.userWalletId) + chooseToTokenBridge.selectWalletTab(fromSwapCurrencyStatus.userWalletId) + } else if (fromSwapCurrencyStatus == null && toSwapCurrencyStatus != null) { + chooseFromTokenBridge.selectWalletTab(toSwapCurrencyStatus.userWalletId) + chooseToTokenBridge.selectWalletTab(toSwapCurrencyStatus.userWalletId) + } else if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null) { + chooseFromTokenBridge.selectWalletTab(fromSwapCurrencyStatus.userWalletId) + chooseToTokenBridge.selectWalletTab(toSwapCurrencyStatus.userWalletId) + } + } + + private fun filterTokensFromSelector() { + val tokenFilter = { accountStatus: AccountStatus, currencyStatus: CryptoCurrencyStatus -> + if (currencyStatus.currency.isCustom) { + false + } else { + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + + (fromSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || + fromSwapCurrencyStatus.currency.id != currencyStatus.currency.id) && + (toSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || + toSwapCurrencyStatus.currency.id != currencyStatus.currency.id) + } + } + + chooseFromTokenBridge.tokenFilter.value = tokenFilter + chooseToTokenBridge.tokenFilter.value = tokenFilter + } + private fun sendSuccessSwapEvent(fromToken: CryptoCurrency, feeType: FeeType) { val event = AnalyticsParam.TxSentFrom.Swap( blockchain = fromToken.network.name, @@ -1815,7 +1480,7 @@ internal class SwapModel @Inject constructor( } private fun getFeeToken(): CryptoCurrency { - val fromToken = requireNotNull(dataState.fromCryptoCurrency) { + val fromToken = requireNotNull(dataState.fromSwapCurrencyStatus) { "fromCryptoCurrency should not be null" } return when (val fee = getSelectedFee()) { @@ -1826,23 +1491,6 @@ internal class SwapModel @Inject constructor( } } - private fun sendApproveSuccessEvent(fromToken: CryptoCurrency, feeType: FeeType, approveType: SwapApproveType) { - val feeToken = getFeeToken().symbol - val event = AnalyticsParam.TxSentFrom.Approve( - blockchain = fromToken.network.name, - token = fromToken.symbol, - feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()), - permissionType = approveType.getNameForAnalytics(), - feeToken = feeToken, - ) - analyticsEventHandler.send( - Basic.TransactionSent( - sentFrom = event, - memoType = Basic.TransactionSent.MemoType.Null, - ), - ) - } - private fun findAndSelectProvider(providerId: String): SwapProvider? { val selectedProvider = dataState.lastLoadedSwapStates.keys.firstOrNull { it.providerId == providerId } if (selectedProvider != null) { @@ -1862,7 +1510,7 @@ internal class SwapModel @Inject constructor( if (!fromAmountFiat.isNullOrZero() && !toAmountFiat.isNullOrZero()) { fromAmountFiat.divide( toAmountFiat, - toTokenInfo.cryptoCurrencyStatus.currency.decimals, + toTokenInfo.swapCurrencyStatus.currency.decimals, RoundingMode.HALF_UP, ) } else { @@ -1901,95 +1549,34 @@ internal class SwapModel @Inject constructor( ) } - private fun findSwapProviders(fromToken: CryptoCurrencyStatus, toToken: CryptoCurrencyStatus): List { - val groupToFind = if (isOrderReversed.value) { - dataState.tokensDataState?.fromGroup - } else { - dataState.tokensDataState?.toGroup - } ?: return emptyList() - - val idToFind = if (isOrderReversed.value) { - fromToken.currency.id.value - } else { - toToken.currency.id.value - } - - return groupToFind.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) -> - currencyList.find { accountSwapCurrency -> - idToFind == accountSwapCurrency.cryptoCurrencyStatus.currency.id.value && - accountSwapCurrency.isAvailable - } - }?.providers - ?.filterForTangemPayWithdrawal() - .orEmpty() - } - - /** - * @return true if swap is not supported and UI was updated to show error state - */ private fun handleSwapNotSupported( - state: TokensDataStateExpress, - fromToken: CryptoCurrencyStatus, - toToken: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, - ): Boolean { - val selectedCurrency = if (isOrderReversed.value) fromToken else toToken - if (isTokenAvailableForSwap(state, selectedCurrency, isOrderReversed.value)) return false - + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + ) { + val fromCurrency = fromSwapCurrencyStatus.currency + val toCurrency = toSwapCurrencyStatus.currency analyticsEventHandler.send( SwapEvents.NoticeUnavailableToSwapPair( - sendToken = fromToken.currency.symbol, - receiveToken = toToken.currency.symbol, - sendBlockchain = fromToken.currency.network.name, - receiveBlockchain = toToken.currency.network.name, + sendToken = fromCurrency.symbol, + receiveToken = toCurrency.symbol, + sendBlockchain = fromCurrency.network.name, + receiveBlockchain = toCurrency.network.name, ), ) // Cancel periodic quote task if selected token is not supported singleTaskScheduler.cancelTask() - // Reset data state - dataState = SwapProcessDataState( - tokensDataState = dataState.tokensDataState, - ) + lastReducedBalanceBy.value = BigDecimal.ZERO lastAmount.value = INITIAL_AMOUNT uiState = stateBuilder.createSwapNotSupportedState( uiStateHolder = uiState, - fromToken = fromToken, - toToken = toToken, - fromAccount = fromAccount, - toAccount = toAccount, - mainTokenId = initialCurrencyFrom.id.value, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, ) - return true - } - - private fun isTokenAvailableForSwap( - state: TokensDataStateExpress, - selectedCurrency: CryptoCurrencyStatus, - isReverseFromTo: Boolean, - ): Boolean { - val group = if (isReverseFromTo) state.fromGroup else state.toGroup - val idToFind = selectedCurrency.currency.id.value - - return group.accountCurrencyList.any { (_, currencyList) -> - currencyList.any { accountSwapCurrency -> - idToFind == accountSwapCurrency.cryptoCurrencyStatus.currency.id.value && - accountSwapCurrency.isAvailable - } - } } private fun isTangemPayWithdrawal(): Boolean { - return tangemPayInput?.isWithdrawal == true || dataState.fromAccount is Account.Payment - } - - private fun List.filterForTangemPayWithdrawal(): List { - return if (isTangemPayWithdrawal()) { - filter { it.type == ExchangeProviderType.CEX } - } else { - this - } + return tangemPayInput?.isWithdrawal == true || dataState.fromSwapCurrencyStatus?.account is Account.Payment } private fun Map.getLastLoadedSuccessStates(): SuccessLoadedSwapData { @@ -2003,29 +1590,9 @@ internal class SwapModel @Inject constructor( } } - private fun isReverseSwapPossible(): Boolean { - if (tangemPayInput != null) return false - val from = dataState.fromCryptoCurrency ?: return false - val to = dataState.toCryptoCurrency ?: return false - - val currenciesGroup = if (isOrderReversed.value) { - dataState.tokensDataState?.toGroup - } else { - dataState.tokensDataState?.fromGroup - } ?: return false - - val chosen = if (isOrderReversed.value) from else to - - return currenciesGroup.accountCurrencyList.flatMap { accountSwapAvailability -> - accountSwapAvailability.currencyList.map { accountSwapCurrency -> - accountSwapCurrency.cryptoCurrencyStatus - } - }.map { currencyStatus -> currencyStatus.currency }.contains(chosen.currency) - } - private fun sendNoticePermissionNeededEvent() { - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol ?: return - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return + val sendTokenSymbol = dataState.fromSwapCurrencyStatus?.currency?.symbol ?: return + val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return val provider = dataState.selectedProvider ?: return analyticsEventHandler.send( SwapEvents.NoticePermissionNeeded( @@ -2037,8 +1604,8 @@ internal class SwapModel @Inject constructor( } private fun sendGivePermissionClickedEvent() { - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol ?: return - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return + val sendTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return + val receiveTokenSymbol = dataState.toSwapCurrencyStatus?.currency?.symbol ?: return val provider = dataState.selectedProvider ?: return analyticsEventHandler.send( SwapEvents.ButtonGivePermissionClicked( @@ -2049,27 +1616,14 @@ internal class SwapModel @Inject constructor( ) } - private fun sendPermissionApproveClickedEvent() { - val sendTokenSymbol = dataState.fromCryptoCurrency?.currency?.symbol ?: return - val receiveTokenSymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return - val approveType = uiState.permissionState.getApproveTypeOrNull() ?: return - val provider = dataState.selectedProvider ?: return - - analyticsEventHandler.send( - SwapEvents.ButtonPermissionApproveClicked( - sendToken = sendTokenSymbol, - receiveToken = receiveTokenSymbol, - approveType = approveType, - provider = provider, - ), - ) - } - private fun updateWalletBalance() { - dataState.fromCryptoCurrency?.currency?.network?.let { network -> + dataState.fromSwapCurrencyStatus?.let { fromSwapCurrencyStatus -> modelScope.launch { withContext(NonCancellable) { - updateForBalance(userWalletId, network) + updateForBalance( + fromSwapCurrencyStatus.userWalletId, + fromSwapCurrencyStatus.currency.network, + ) } } } @@ -2083,13 +1637,6 @@ internal class SwapModel @Inject constructor( ) } - private fun ApproveType.toDomainApproveType(): SwapApproveType { - return when (this) { - ApproveType.LIMITED -> SwapApproveType.LIMITED - ApproveType.UNLIMITED -> SwapApproveType.UNLIMITED - } - } - private fun triggerPromoProviderEvent(recommendedProvider: SwapProvider?, bestQuotesProvider: SwapProvider?) { // for now send event only for changelly if (recommendedProvider == null || @@ -2116,15 +1663,17 @@ internal class SwapModel @Inject constructor( private fun onFailedTxEmailClick(errorMessage: String) { modelScope.launch { val transaction = dataState.swapDataModel?.transaction - val fromCurrencyStatus = dataState.fromCryptoCurrency ?: initialFromStatus - val network = fromCurrencyStatus.currency.network + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + val fromCurrency = fromSwapCurrencyStatus?.currency ?: params.cryptoCurrency + val fromWalletId = fromSwapCurrencyStatus?.userWalletId ?: params.userWalletId + val network = fromCurrency?.network saveBlockchainErrorUseCase( error = BlockchainErrorInfo( errorMessage = errorMessage, - networkId = network.id, + networkId = network?.id, destinationAddress = transaction?.txTo.orEmpty(), - tokenSymbol = fromCurrencyStatus.currency.symbol, + tokenSymbol = fromCurrency?.symbol.orEmpty(), amount = dataState.amount.orEmpty(), fee = when (val fee = getSelectedFee()) { is TxFee.FeeComponent -> fee.fee.amount.value?.toString() @@ -2134,7 +1683,7 @@ internal class SwapModel @Inject constructor( ), ) - val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId) + val metaInfo = getWalletMetaInfoUseCase(fromWalletId) .getOrElse { error("CardInfo must be not null") } val email = FeedbackEmailType.SwapProblem( @@ -2148,28 +1697,6 @@ internal class SwapModel @Inject constructor( } } - private fun CryptoCurrency.getNetworkInfo(): NetworkInfo { - return NetworkInfo( - name = this.network.name, - blockchainId = this.network.rawId, - ) - } - - private suspend fun getFromStatus(): CryptoCurrencyStatus? { - return if (tangemPayInput != null) { - getTangemPayCurrencyStatusUseCase( - currency = initialCurrencyFrom, - cryptoAmount = tangemPayInput.cryptoAmount, - fiatAmount = tangemPayInput.fiatAmount, - depositAddress = tangemPayInput.depositAddress, - ) - } else { - singleAccountStatusListSupplier.getSyncOrNull(params.userWalletId) - .getCryptoCurrencyStatus(currency = initialCurrencyFrom) - .getOrNull() - } - } - private fun getSelectedFeeState(): TxFeeSealedState { val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content @@ -2222,8 +1749,8 @@ internal class SwapModel @Inject constructor( override suspend fun loadFeeExtended( selectedToken: CryptoCurrencyStatus?, ): Either { - val fromToken = dataState.fromCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) - val toToken = dataState.toCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) + val fromSwapCurrencyStatus = + dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! if (selectedProvider.type != ExchangeProviderType.CEX) { @@ -2239,10 +1766,7 @@ internal class SwapModel @Inject constructor( } return swapInteractor.loadFeeForSwapTransaction( - fromToken = fromToken, - fromAccount = dataState.fromAccount, - toToken = toToken, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, provider = selectedProvider, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, @@ -2261,9 +1785,11 @@ internal class SwapModel @Inject constructor( return } + val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus + // If fee currency is same as from currency, we need to reload quotes to update fee info val isFeeCurrencySameAsFromCurrency = newState is FeeSelectorUM.Content && - dataState.fromCryptoCurrency?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id + fromSwapCurrencyStatus?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id // If fee currency is coin, we need to reload quotes to update fee related warnings (e.g. insufficient funds) val isCoinFeeSelected = newState is FeeSelectorUM.Content && @@ -2296,8 +1822,10 @@ internal class SwapModel @Inject constructor( override suspend fun loadFee(): Either { TangemLogger.e("loadFee: Start loading fee") - val fromToken = dataState.fromCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) - val toToken = dataState.toCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError) + val fromSwapCurrencyStatus = + dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) + val toSwapCurrencyStatus = + dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { @@ -2311,20 +1839,16 @@ internal class SwapModel @Inject constructor( } return swapInteractor.loadFeeForSwapTransaction( - fromToken = fromToken, - fromAccount = dataState.fromAccount, - toToken = toToken, - toAccount = dataState.toAccount, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, provider = selectedProvider, amount = lastAmount.value, reduceBalanceBy = lastReducedBalanceBy.value, - ) - .onLeft { - TangemLogger.e("loadFee: Failed to load fee with error $it") - } - .onRight { - TangemLogger.e("loadFee: Fee loaded successfully") - } + ).onLeft { + TangemLogger.e("loadFee: Failed to load fee with error $it") + }.onRight { + TangemLogger.e("loadFee: Fee loaded successfully") + } } override fun choosingInProgress(updatedState: Boolean) { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 6586b11550..5406397bbd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -37,15 +37,6 @@ internal class SwapNotificationsFactory( private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { - fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList { - return persistentListOf( - SwapNotificationUM.Warning.ExpressGeneralError( - code = code, - onConfirmClick = onRefreshClick, - ), - ) - } - fun getGeneralErrorStateNotifications( message: TextReference?, onClick: () -> Unit, @@ -104,7 +95,6 @@ internal class SwapNotificationsFactory( @Suppress("LongParameterList") fun getConfirmationStateNotifications( quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, selectedFeeType: FeeType, providerName: String, @@ -114,9 +104,9 @@ internal class SwapNotificationsFactory( maybeAddRentExemptionError(quoteModel) maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType) maybeAddNeedReserveToCreateAccountWarning(quoteModel) - maybeAddPermissionNeededWarning(quoteModel, fromToken, providerName) + maybeAddPermissionNeededWarning(quoteModel, providerName) maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType) - maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee) + maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, hideFee) maybeAddTransactionInProgressWarning(quoteModel) maybeAddPriceImpactNotification(quoteModel.priceImpact) } @@ -135,7 +125,7 @@ internal class SwapNotificationsFactory( if (quoteModel.permissionState is PermissionDataState.PermissionLoading) { add(SwapNotificationUM.Error.ApprovalInProgressWarning) } else if (quoteModel.preparedSwapConfigState.hasOutgoingTransaction) { - val fromCurrency = quoteModel.fromTokenInfo.cryptoCurrencyStatus.currency + val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency add( SwapNotificationUM.Error.TransactionInProgressWarning( currencySymbol = fromCurrency.network.currencySymbol, @@ -162,7 +152,7 @@ internal class SwapNotificationsFactory( feeCryptoCurrencyStatus: CryptoCurrencyStatus?, selectedFeeType: FeeType, ) { - val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus + val swapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus val includeFeeInAmount = quoteModel.preparedSwapConfigState.includeFeeInAmount val amount = quoteModel.fromTokenInfo.tokenAmount val amountToRequest = if (includeFeeInAmount is IncludeFeeInAmount.Included) { @@ -179,14 +169,14 @@ internal class SwapNotificationsFactory( } is TxFeeState.SingleFeeState -> feeState.fee } - val isCardano = BlockchainUtils.isCardano(fromCurrencyStatus.currency.network.rawId) + val isCardano = BlockchainUtils.isCardano(swapCurrencyStatus.currency.network.rawId) // blockchain specific addExistentialWarningNotification( existentialDeposit = quoteModel.currencyCheck?.existentialDeposit, feeAmount = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, onReduceClick = { reduceBy, reduceByDiff, _ -> actions.onReduceByAmount( // use in swap notification amountToRequest because fee is already subtracted @@ -198,7 +188,7 @@ internal class SwapNotificationsFactory( addValidateTransactionNotifications( dustValue = quoteModel.currencyCheck?.dustValue.orZero(), validationError = quoteModel.validationResult, - cryptoCurrency = fromCurrencyStatus.currency, + cryptoCurrency = swapCurrencyStatus.currency, minAdaValue = quoteModel.minAdaValue, onReduceClick = { reduceTo, _ -> actions.onReduceToAmount(amount.copy(value = reduceTo)) @@ -209,26 +199,26 @@ internal class SwapNotificationsFactory( dustValue = quoteModel.currencyCheck?.dustValue, feeValue = fee?.fee?.amount?.value.orZero(), sendingAmount = amountToRequest.value, - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, feeCurrencyStatus = feeCryptoCurrencyStatus, ) } addReserveAmountErrorNotification( reserveAmount = quoteModel.currencyCheck?.reserveAmount, sendingAmount = amountToRequest.value, - cryptoCurrency = fromCurrencyStatus.currency, + cryptoCurrency = swapCurrencyStatus.currency, feeCryptoCurrency = feeCryptoCurrencyStatus?.currency, isAccountFunded = true, // consider the account is funded on the provider side ) addReduceAmountNotification( - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, fromAmount = quoteModel.fromTokenInfo.tokenAmount, onReduceByAmount = actions.onReduceByAmount, ) addTransactionLimitErrorNotification( currencyCheck = quoteModel.currencyCheck, sendingAmount = amountToRequest.value, - cryptoCurrencyStatus = fromCurrencyStatus, + cryptoCurrencyStatus = swapCurrencyStatus.status, feeCurrencyStatus = feeCryptoCurrencyStatus, feeValue = fee?.feeValue.orZero(), onReduceClick = { reduceTo, _ -> @@ -240,11 +230,11 @@ internal class SwapNotificationsFactory( private fun MutableList.maybeAddNeedReserveToCreateAccountWarning( quoteModel: SwapState.QuotesLoadedState, ) { - val status = quoteModel.toTokenInfo.cryptoCurrencyStatus.value + val status = quoteModel.toTokenInfo.swapCurrencyStatus.status.value if (status is CryptoCurrencyStatus.NoAccount) { val amount = quoteModel.toTokenInfo.tokenAmount.value val amountToCreateAccount = status.amountToCreateAccount - val currencyTo = quoteModel.toTokenInfo.cryptoCurrencyStatus.currency + val currencyTo = quoteModel.toTokenInfo.swapCurrencyStatus.currency if (amount < amountToCreateAccount) { add( SwapNotificationUM.Warning.NeedReserveToCreateAccount( @@ -258,17 +248,13 @@ internal class SwapNotificationsFactory( private fun MutableList.maybeAddPermissionNeededWarning( quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, providerName: String, ) { - if (!quoteModel.preparedSwapConfigState.isAllowedToSpend && - quoteModel.preparedSwapConfigState.feeState is SwapFeeState.Enough && - quoteModel.permissionState is PermissionDataState.PermissionReadyForRequest - ) { + if (quoteModel.permissionState is PermissionDataState.PermissionRequired) { add( SwapNotificationUM.Info.PermissionNeeded( providerName = providerName, - fromTokenSymbol = fromToken.symbol, + fromTokenSymbol = quoteModel.fromTokenInfo.swapCurrencyStatus.currency.symbol, onApproveClick = actions.openPermissionBottomSheet, ), ) @@ -308,27 +294,28 @@ internal class SwapNotificationsFactory( private fun MutableList.maybeAddUnableCoverFeeWarning( quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, + feeCryptoCurrencyStatus: CryptoCurrencyStatus?, hideFee: Boolean, ) { if (hideFee) return + val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && - feeEnoughState.feeCurrency != fromToken + feeCryptoCurrencyStatus?.currency != fromCurrency val isNotEnoughFee = quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough - val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) && + val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) && quoteModel.swapProvider.type == ExchangeProviderType.CEX if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) { add( SwapNotificationUM.Error.UnableToCoverFeeWarning( - fromToken = fromToken, - feeCurrency = feeEnoughState.feeCurrency, - currencyName = feeEnoughState.currencyName ?: fromToken.network.name, - currencySymbol = feeEnoughState.currencySymbol ?: fromToken.network.currencySymbol, + fromToken = fromCurrency, + feeCurrency = feeCryptoCurrencyStatus?.currency, + currencyName = feeEnoughState.currencyName ?: fromCurrency.network.name, + currencySymbol = feeEnoughState.currencySymbol ?: fromCurrency.network.currencySymbol, onConfirmClick = actions.onBuyClick, ), ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt index 1c031215dd..0f2e373166 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapProcessDataState.kt @@ -1,10 +1,10 @@ package com.tangem.feature.swap.model -import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapPairLeast import com.tangem.feature.swap.domain.models.domain.SwapProvider -import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData import com.tangem.feature.swap.domain.models.ui.SwapState import com.tangem.feature.swap.domain.models.ui.TokensDataStateExpress import com.tangem.feature.swap.domain.models.ui.TxFee @@ -12,20 +12,24 @@ import java.math.BigDecimal data class SwapProcessDataState( // Initial network id - val fromCryptoCurrency: CryptoCurrencyStatus? = null, - val toCryptoCurrency: CryptoCurrencyStatus? = null, + val fromSwapCurrencyStatus: SwapCurrencyStatus? = null, + val toSwapCurrencyStatus: SwapCurrencyStatus? = null, + val feePaidCryptoCurrency: CryptoCurrencyStatus? = null, - val fromAccount: Account? = null, - val toAccount: Account? = null, + + // swap info + val pairs: List = emptyList(), + + val selectedPairProviders: List = emptyList(), + val selectedProvider: SwapProvider? = null, + val lastLoadedSwapStates: Map = emptyMap(), + // Amount from input val amount: String? = null, val reduceBalanceBy: BigDecimal = BigDecimal.ZERO, - val approveDataModel: RequestApproveStateData? = null, val swapDataModel: SwapDataModel? = null, val selectedFee: TxFee.Legacy? = null, val tokensDataState: TokensDataStateExpress? = null, - val selectedProvider: SwapProvider? = null, - val lastLoadedSwapStates: Map = emptyMap(), ) { fun getCurrentLoadedSwapState(): SwapState.QuotesLoadedState? { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 0afbd2da4b..f45012a0b1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -4,11 +4,10 @@ import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue import com.tangem.common.ui.account.AccountTitleUM -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.states.FeeItemState import com.tangem.feature.swap.models.states.ProviderState @@ -18,14 +17,13 @@ import kotlinx.collections.immutable.persistentListOf internal data class SwapStateHolder( val sendCardData: SwapCardState, val receiveCardData: SwapCardState, - val blockchainId: String, // not the same as networkId, its local id in app val notifications: ImmutableList = persistentListOf(), val isInsufficientFunds: Boolean, val changeCardsButtonState: ChangeCardsButtonState, val providerState: ProviderState, val fee: FeeItemState = FeeItemState.Empty, - val permissionState: GiveTxPermissionState = GiveTxPermissionState.Empty, + val permissionUM: SwapPermissionUM = SwapPermissionUM.Empty, val priceImpact: PriceImpact, val successState: SwapSuccessStateHolder? = null, @@ -37,34 +35,35 @@ internal data class SwapStateHolder( val onRefresh: () -> Unit, val onBackClicked: () -> Unit, val onChangeCardsClicked: () -> Unit, - val onSelectTokenClick: (() -> Unit), + val onSelectTokenClick: ((TokenSelectionDirection) -> Unit), val onSuccess: (() -> Unit), val onMaxAmountSelected: (() -> Unit)? = null, val onShowPermissionBottomSheet: () -> Unit = {}, ) +@Immutable sealed class SwapCardState { + abstract val type: TransactionCardType + data class SwapCardData( - @DrawableRes val networkIconRes: Int?, - val type: TransactionCardType, + override val type: TransactionCardType, + val currencyIconState: CurrencyIconState, + val tokenSymbol: TextReference, val amountEquivalent: TextReference?, - val token: CryptoCurrencyStatus?, - val coinId: String?, val amountTextFieldValue: TextFieldValue?, - val tokenIconUrl: String?, - val tokenCurrency: String, val balance: String, val isBalanceHidden: Boolean, - val isNotNativeToken: Boolean, - val canSelectAnotherToken: Boolean = false, ) : SwapCardState() data class Empty( - val type: TransactionCardType, - val amountEquivalent: TextReference?, + override val type: TransactionCardType, + val amountEquivalent: TextReference, val amountTextFieldValue: TextFieldValue?, - val canSelectAnotherToken: Boolean = false, + ) : SwapCardState() + + data class Loading( + override val type: TransactionCardType, ) : SwapCardState() } @@ -79,21 +78,21 @@ data class SwapButton( @Immutable sealed interface TransactionCardType { - val accountTitleUM: AccountTitleUM? + val accountTitleUM: AccountTitleUM val inputError: InputError data class Inputtable( val onAmountChanged: ((String) -> Unit), val onFocusChanged: ((Boolean) -> Unit), override val inputError: InputError, - override val accountTitleUM: AccountTitleUM?, + override val accountTitleUM: AccountTitleUM, ) : TransactionCardType data class ReadOnly( val shouldShowWarning: Boolean = false, val onWarningClick: (() -> Unit)? = null, override val inputError: InputError = InputError.Empty, - override val accountTitleUM: AccountTitleUM? = null, + override val accountTitleUM: AccountTitleUM, ) : TransactionCardType sealed interface InputError { @@ -116,4 +115,14 @@ data class LegalState( enum class ChangeCardsButtonState { ENABLED, DISABLED, UPDATE_IN_PROGRESS +} + +sealed class SwapPermissionUM { + + data class PermissionRequired( + val isResetApproval: Boolean, + val spenderAddress: String, + ) : SwapPermissionUM() + + object Empty : SwapPermissionUM() } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt new file mode 100644 index 0000000000..67761cb85d --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/TokenSelectionDirection.kt @@ -0,0 +1,6 @@ +package com.tangem.feature.swap.models + +internal enum class TokenSelectionDirection { + FROM, + TO, +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index f6bddc0a70..ac922cd6f9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -1,33 +1,28 @@ package com.tangem.feature.swap.models -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.ui.TxFee import java.math.BigDecimal -data class UiActions( +internal data class UiActions( val onAmountChanged: (String) -> Unit, val onAmountSelected: (Boolean) -> Unit, val onSwapClick: () -> Unit, - val onGivePermissionClick: () -> Unit, val onChangeCardsClicked: () -> Unit, val onBackClicked: () -> Unit, val onMaxAmountSelected: () -> Unit, val onReduceToAmount: (SwapAmount) -> Unit, val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit, val openPermissionBottomSheet: () -> Unit, - val onChangeApproveType: (ApproveType) -> Unit, // region new actions val onRetryClick: () -> Unit, val onClickFee: () -> Unit, val onSelectFeeType: (TxFee.Legacy) -> Unit, val onProviderClick: (String) -> Unit, val onProviderSelect: (String) -> Unit, - val onBuyClick: (CryptoCurrency) -> Unit, - val onSelectTokenClick: () -> Unit, + val onBuyClick: () -> Unit, + val onSelectTokenClick: (TokenSelectionDirection) -> Unit, val onSuccess: () -> Unit, val onLinkClick: (String) -> Unit, val onReceiveCardWarningClick: () -> Unit, - val onOpenLearnMoreAboutApproveClick: () -> Unit, ) \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index d6c929a2f5..ff6f8b47ce 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -60,7 +60,7 @@ internal object SwapNotificationUM { val currencyName: String, val currencySymbol: String, val feeCurrency: CryptoCurrency?, - val onConfirmClick: (CryptoCurrency) -> Unit, + val onConfirmClick: () -> Unit, ) : Error( title = resourceReference( R.string.warning_express_not_enough_fee_for_token_tx_title, @@ -74,7 +74,7 @@ internal object SwapNotificationUM { buttonState = feeCurrency?.let { NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)), - onClick = { onConfirmClick(it) }, + onClick = onConfirmClick, ) }, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt new file mode 100644 index 0000000000..9826e46d2f --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRoute.kt @@ -0,0 +1,9 @@ +package com.tangem.feature.swap.router + +import com.tangem.core.decompose.navigation.Route + +internal sealed interface SwapRoute : Route { + data object Main : SwapRoute + data object Success : SwapRoute + data class SelectToken(val isFromDirection: Boolean) : SwapRoute +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt deleted file mode 100644 index a3a55feafd..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/router/SwapRouter.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.tangem.feature.swap.router - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRouter -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId - -internal class SwapRouter( - private val router: AppRouter, -) { - - var currentScreen by mutableStateOf(SwapNavScreen.Main) - private set - - fun openScreen(screen: SwapNavScreen) { - currentScreen = screen - } - - fun back() { - if (currentScreen == SwapNavScreen.SelectToken) { - currentScreen = SwapNavScreen.Main - } else { - val selectTokensIndex = router.stack.getSelectTokensRouteIndexOrNull() - - /* - * If select token screen is not in stack, then just pop to previous screen. - * Otherwise, pop to previous screen that was before select token screen. - */ - if (currentScreen == SwapNavScreen.Success && selectTokensIndex != null) { - // find previous screen that was before select token - val prevRoute = router.stack.getOrNull(index = selectTokensIndex - 1) - - if (prevRoute != null) { - router.popTo(prevRoute) - } else { - router.pop() - } - } else { - router.pop() - } - } - } - - fun openTokenDetails(userWalletId: UserWalletId, currency: CryptoCurrency) { - val route = AppRoute.CurrencyDetails( - userWalletId = userWalletId, - currency = currency, - ) - - if (route in router.stack) { - router.popTo(route) - } else { - router.pop { - router.push(route) - } - } - } - - private fun List.getSelectTokensRouteIndexOrNull(): Int? { - return this - .indexOfFirst { it::class == AppRoute.SwapCrypto::class } - .takeIf { it != -1 } - } -} - -enum class SwapNavScreen { - Main, Success, SelectToken -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index bc7e3c6bb1..2c2551b3d5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -7,12 +7,10 @@ import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM -import com.tangem.common.ui.bottomsheet.permission.state.* +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.common.ui.extensions.iconResId import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.* import com.tangem.core.ui.res.TangemTheme @@ -20,14 +18,13 @@ import com.tangem.domain.appcurrency.model.AppCurrency 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.wallet.UserWallet import com.tangem.domain.models.wallet.isHotWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork 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.IncludeFeeInAmount -import com.tangem.feature.swap.domain.models.domain.NetworkInfo import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.model.SwapNotificationsFactory @@ -40,87 +37,54 @@ import com.tangem.utils.Provider import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.TILDE_SIGN +import com.tangem.utils.TangemBlogUrlBuilder.FEE_BLOG_LINK import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal import java.math.RoundingMode -import java.util.Locale import kotlin.math.min /** * State builder creates a specific states for SwapScreen */ -@Suppress("LargeClass", "TooManyFunctions", "LongParameterList") +@Suppress("LargeClass", "TooManyFunctions") internal class StateBuilder( - private val userWalletProvider: Provider, private val actions: UiActions, private val isBalanceHiddenProvider: Provider, private val appCurrencyProvider: Provider, private val isAccountsModeProvider: Provider, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, ) { - - private val isHoldToConfirmEnabled: Boolean = userWalletProvider().isHotWallet - private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) { SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork) } - fun createInitialLoadingState( - initialCurrencyFrom: CryptoCurrency, - initialCurrencyTo: CryptoCurrency?, - fromNetworkInfo: NetworkInfo, - ): SwapStateHolder { + fun createInitialLoadingState(): SwapStateHolder { return SwapStateHolder( - blockchainId = fromNetworkInfo.blockchainId, - sendCardData = SwapCardState.SwapCardData( - type = TransactionCardType.Inputtable( - onAmountChanged = actions.onAmountChanged, - onFocusChanged = actions.onAmountSelected, - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = null, - ), - amountEquivalent = null, - amountTextFieldValue = null, - token = null, - tokenIconUrl = initialCurrencyFrom.iconUrl, - tokenCurrency = initialCurrencyFrom.symbol, - coinId = initialCurrencyFrom.network.rawId, - canSelectAnotherToken = false, - isNotNativeToken = initialCurrencyFrom is CryptoCurrency.Token, - balance = "", - networkIconRes = initialCurrencyFrom.network.iconResId, - isBalanceHidden = true, + sendCardData = getEmptyCardState( + isFromCard = true, + emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY), ), - receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly(), - amountEquivalent = null, - tokenIconUrl = initialCurrencyTo?.iconUrl, - tokenCurrency = initialCurrencyTo?.symbol.orEmpty(), - token = null, - amountTextFieldValue = null, - canSelectAnotherToken = false, - balance = "", - isNotNativeToken = initialCurrencyTo is CryptoCurrency.Token, - networkIconRes = initialCurrencyTo?.network?.iconResId, - coinId = initialCurrencyTo?.network?.rawId, - isBalanceHidden = true, + receiveCardData = getEmptyCardState( + isFromCard = false, + emptyAmountState = SwapState.EmptyAmountState(TextReference.EMPTY), ), fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = null, isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isInProgress = true, + isHoldToConfirm = false, onClick = {}, ), onRefresh = {}, onBackClicked = actions.onBackClicked, onChangeCardsClicked = actions.onChangeCardsClicked, onMaxAmountSelected = actions.onMaxAmountSelected, - changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, + changeCardsButtonState = ChangeCardsButtonState.DISABLED, onShowPermissionBottomSheet = actions.openPermissionBottomSheet, onSelectTokenClick = actions.onSelectTokenClick, onSuccess = actions.onSuccess, @@ -131,101 +95,214 @@ internal class StateBuilder( ) } - fun createNoAvailableTokensToSwapState( + fun createInitialReadyState( uiStateHolder: SwapStateHolder, - fromToken: CryptoCurrencyStatus, + emptyAmountState: SwapState.EmptyAmountState, + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, ): SwapStateHolder { - if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), - amountTextFieldValue = null, - amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = fromToken, - tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.rawId, - isNotNativeToken = fromToken.currency is CryptoCurrency.Token, - tokenCurrency = fromToken.currency.symbol, - canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - balance = fromToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = fromToken.currency.network.iconResId, - isBalanceHidden = isBalanceHiddenProvider(), + sendCardData = createCardState( + swapCurrencyStatus = fromSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = true, ), - receiveCardData = SwapCardState.Empty( - type = TransactionCardType.ReadOnly(), - amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - amountTextFieldValue = TextFieldValue( - text = "0", - ), - canSelectAnotherToken = true, + receiveCardData = createCardState( + swapCurrencyStatus = toSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = false, ), - notifications = notificationsFactory.getNotAvailableStateNotifications(fromToken.currency.name), + notifications = persistentListOf(), + isInsufficientFunds = false, fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, onClick = { }, ), - changeCardsButtonState = ChangeCardsButtonState.DISABLED, + shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, + providerState = ProviderState.Empty(), priceImpact = PriceImpact.Empty, ) } + fun updateCurrenciesState( + uiStateHolder: SwapStateHolder, + emptyAmountState: SwapState.EmptyAmountState, + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, + shouldResetAmount: Boolean, + ): SwapStateHolder { + return uiStateHolder.copy( + sendCardData = uiStateHolder.sendCardData.updateCurrencyStatus( + swapCurrencyStatus = fromSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = true, + shouldResetAmount = shouldResetAmount, + ), + receiveCardData = uiStateHolder.receiveCardData.updateCurrencyStatus( + swapCurrencyStatus = toSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = false, + shouldResetAmount = shouldResetAmount, + ), + notifications = persistentListOf(), + isInsufficientFunds = false, + fee = FeeItemState.Empty, + swapButton = SwapButton( + walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), + isEnabled = false, + isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, + onClick = { }, + ), + shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, + providerState = ProviderState.Empty(), + priceImpact = PriceImpact.Empty, + ) + } + + private fun SwapCardState.updateCurrencyStatus( + swapCurrencyStatus: SwapCurrencyStatus?, + emptyAmountState: SwapState.EmptyAmountState, + shouldResetAmount: Boolean, + isFromCard: Boolean, + ): SwapCardState { + val cardType = if (isFromCard) { + TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, true), + ) + } else { + TransactionCardType.ReadOnly( + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, false), + ) + } + return if (this !is SwapCardState.SwapCardData || swapCurrencyStatus == null) { + createCardState( + swapCurrencyStatus = swapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = isFromCard, + ) + } else if (shouldResetAmount) { + copy( + amountTextFieldValue = if (isFromCard) { + null + } else { + TextFieldValue("0".appendApproximateSign()) + }, + amountEquivalent = emptyAmountState.zeroAmountEquivalent, + currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + isBalanceHidden = isBalanceHiddenProvider(), + type = cardType, + ) + } else { + copy( + currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + isBalanceHidden = isBalanceHiddenProvider(), + type = cardType, + ) + } + } + + private fun createCardState( + swapCurrencyStatus: SwapCurrencyStatus?, + emptyAmountState: SwapState.EmptyAmountState, + isFromCard: Boolean, + ): SwapCardState { + return if (swapCurrencyStatus == null) { + getEmptyCardState(isFromCard = isFromCard, emptyAmountState = emptyAmountState) + } else { + SwapCardState.SwapCardData( + type = if (isFromCard) { + TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, true), + ) + } else { + TransactionCardType.ReadOnly( + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, false), + ) + }, + amountTextFieldValue = if (isFromCard) { + null + } else { + TextFieldValue("0".appendApproximateSign()) + }, + amountEquivalent = emptyAmountState.zeroAmountEquivalent, + currencyIconState = iconStateConverter.convert(swapCurrencyStatus.status), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + balance = swapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), + isBalanceHidden = isBalanceHiddenProvider(), + ) + } + } + + private fun getEmptyCardState(isFromCard: Boolean, emptyAmountState: SwapState.EmptyAmountState) = + SwapCardState.Empty( + type = TransactionCardType.ReadOnly( + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Text( + title = resourceReference( + if (isFromCard) R.string.swapping_from_title else R.string.swapping_to_title, + ), + ), + ), + amountTextFieldValue = TextFieldValue(text = if (isFromCard) "0" else "0".appendApproximateSign()), + amountEquivalent = emptyAmountState.zeroAmountEquivalent, + ) + fun createSwapNotSupportedState( uiStateHolder: SwapStateHolder, - fromToken: CryptoCurrencyStatus, - toToken: CryptoCurrencyStatus, - fromAccount: Account?, - toAccount: Account?, - mainTokenId: String, + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, ): SwapStateHolder { - val canSelectSendToken = mainTokenId != fromToken.currency.id.value - val canSelectReceiveToken = mainTokenId != toToken.currency.id.value if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( sendCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( - accountTitleUM = getFromCardAccountTitle(fromAccount), + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), ), amountTextFieldValue = TextFieldValue( text = "0", ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = fromToken, - tokenIconUrl = fromToken.currency.iconUrl, - coinId = fromToken.currency.network.rawId, - isNotNativeToken = fromToken.currency is CryptoCurrency.Token, - tokenCurrency = fromToken.currency.symbol, - canSelectAnotherToken = canSelectSendToken, - balance = fromToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = fromToken.currency.network.iconResId, + currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol), + balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( - accountTitleUM = getToCardAccountTitle(toAccount), + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), ), amountTextFieldValue = TextFieldValue( text = "0", ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = toToken, - tokenIconUrl = toToken.currency.iconUrl, - coinId = toToken.currency.network.rawId, - isNotNativeToken = toToken.currency is CryptoCurrency.Token, - tokenCurrency = toToken.currency.symbol, - canSelectAnotherToken = canSelectReceiveToken, - balance = toToken.getFormattedAmount(isNeedSymbol = false), - networkIconRes = toToken.currency.network.iconResId, + currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), + tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), + balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), notifications = notificationsFactory.getSwapNotSupportedNotifications(), fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = { }, ), changeCardsButtonState = ChangeCardsButtonState.DISABLED, @@ -236,115 +313,76 @@ internal class StateBuilder( @Suppress("LongParameterList") fun createQuotesLoadingState( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, uiStateHolder: SwapStateHolder, - fromToken: CryptoCurrency, - toToken: CryptoCurrency, - mainTokenId: String, - fromAccount: Account?, - toAccount: Account?, ): SwapStateHolder { - val canSelectSendToken = mainTokenId != fromToken.id.value - val canSelectReceiveToken = mainTokenId != toToken.id.value + val fromCurrency = fromSwapCurrencyStatus.currency + val toCurrency = toSwapCurrencyStatus.currency if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder - val sendInputType = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable) - val sendInput = if (sendInputType.inputError !is TransactionCardType.InputError.Empty) { - sendInputType - } else { - sendInputType.copy( - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = getFromCardAccountTitle(fromAccount), - ) - } return uiStateHolder.copy( - sendCardData = SwapCardState.SwapCardData( - type = sendInput, - amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, - amountEquivalent = null, - token = uiStateHolder.sendCardData.token, - tokenIconUrl = fromToken.iconUrl, - tokenCurrency = fromToken.symbol, - coinId = fromToken.network.rawId, - isNotNativeToken = fromToken is CryptoCurrency.Token, - canSelectAnotherToken = canSelectSendToken, - balance = if (!canSelectSendToken) uiStateHolder.sendCardData.balance else "", - networkIconRes = fromToken.network.iconResId, - isBalanceHidden = isBalanceHiddenProvider(), + sendCardData = uiStateHolder.sendCardData.copy( + type = TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + ), ), - receiveCardData = SwapCardState.SwapCardData( + receiveCardData = uiStateHolder.receiveCardData.copy( type = TransactionCardType.ReadOnly( - accountTitleUM = getToCardAccountTitle(toAccount), + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), ), amountTextFieldValue = null, amountEquivalent = null, - token = uiStateHolder.receiveCardData.token, - tokenIconUrl = toToken.iconUrl, - tokenCurrency = toToken.symbol, - coinId = toToken.network.rawId, - isNotNativeToken = toToken is CryptoCurrency.Token, - canSelectAnotherToken = canSelectReceiveToken, - balance = if (!canSelectReceiveToken) uiStateHolder.receiveCardData.balance else "", - networkIconRes = toToken.network.iconResId, - isBalanceHidden = isBalanceHiddenProvider(), ), notifications = persistentListOf(), fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = {}, ), providerState = ProviderState.Loading(), - permissionState = uiStateHolder.permissionState, + permissionUM = uiStateHolder.permissionUM, changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, priceImpact = PriceImpact.Empty, - shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toToken), + shouldShowMaxAmount = shouldShowMaxAmount(fromCurrency, toCurrency), ) } - /** - * Create quotes loaded state - * - * @param uiStateHolder whole screen state - * @param quoteModel data model - * @param fromToken token data to swap - * @return updated whole screen state - */ @Suppress("LongMethod", "LongParameterList") fun createQuotesLoadedState( uiStateHolder: SwapStateHolder, quoteModel: SwapState.QuotesLoadedState, - fromToken: CryptoCurrency, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, swapProvider: SwapProvider, bestRatedProviderId: String, isNeedBestRateBadge: Boolean, selectedFeeType: FeeType, - isReverseSwapPossible: Boolean, needApplyFCARestrictions: Boolean, hideFee: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType) + val fromSwapCurrencyStatus = quoteModel.fromTokenInfo.swapCurrencyStatus + val toSwapCurrencyStatus = quoteModel.toTokenInfo.swapCurrencyStatus + val isInsufficientFunds = isInsufficientFundsCondition(quoteModel) + val notifications = notificationsFactory.getConfirmationStateNotifications( quoteModel = quoteModel, - fromToken = fromToken, feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, selectedFeeType = selectedFeeType, providerName = swapProvider.name, hideFee = hideFee, ) - val feeState = if (hideFee) FeeItemState.Empty else createFeeState(quoteModel.txFee, selectedFeeType) - val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus - val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus - val isInsufficientFunds = isInsufficientFundsCondition(quoteModel) - val fromAccount = quoteModel.fromTokenInfo.account - val toAccount = quoteModel.toTokenInfo.account val fromAccountTitleUM = when { isInsufficientFunds -> AccountTitleUM.Text(TextReference.Res(R.string.swapping_insufficient_funds)) - else -> getFromCardAccountTitle(fromAccount) + else -> getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true) } val sendCardType = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable) val sendInput = when (sendCardType.inputError) { @@ -369,22 +407,17 @@ internal class StateBuilder( sendCardData = SwapCardState.SwapCardData( type = sendInput, amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, - amountEquivalent = getFormattedFiatAmount(quoteModel.fromTokenInfo.amountFiat), - token = fromCurrencyStatus, - tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = fromCurrencyStatus.currency.network.rawId, - isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.sendCardData.networkIconRes, - balance = fromCurrencyStatus.getFormattedAmount(isNeedSymbol = false), + amountEquivalent = uiStateHolder.sendCardData.amountEquivalent, + currencyIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + tokenSymbol = stringReference(fromSwapCurrencyStatus.currency.symbol), + balance = fromSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), receiveCardData = SwapCardState.SwapCardData( type = TransactionCardType.ReadOnly( shouldShowWarning = true, onWarningClick = actions.onReceiveCardWarningClick, - accountTitleUM = getToCardAccountTitle(toAccount), + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), ), amountTextFieldValue = TextFieldValue( quoteModel.toTokenInfo.tokenAmount @@ -411,34 +444,24 @@ internal class StateBuilder( } else { getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat) }, - token = toCurrencyStatus, - tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toCurrencyStatus.currency.network.rawId, - isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.receiveCardData.networkIconRes, - balance = toCurrencyStatus.getFormattedAmount(isNeedSymbol = false), + currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), + tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), + balance = toSwapCurrencyStatus.status.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ), isInsufficientFunds = isInsufficientFundsCondition(quoteModel), notifications = notifications, - permissionState = convertPermissionState( - lastPermissionState = uiStateHolder.permissionState, + permissionUM = convertPermissionState( permissionDataState = quoteModel.permissionState, - providerName = swapProvider.name, - onGivePermissionClick = actions.onGivePermissionClick, - onChangeApproveType = actions.onChangeApproveType, - onOpenLearnMoreAboutApproveClick = actions.onOpenLearnMoreAboutApproveClick, ), fee = feeState, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = getSwapButtonEnabled(notifications, priceImpact), - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = actions.onSwapClick, ), - changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = swapProvider.convertToContentClickableProviderState( isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(), fromTokenInfo = quoteModel.fromTokenInfo, @@ -451,12 +474,12 @@ internal class StateBuilder( ), priceImpact = priceImpact, tosState = createTosState(swapProvider), - shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrencyStatus.currency), + shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus.currency, toSwapCurrencyStatus.currency), ) } - private fun shouldShowMaxAmount(fromToken: CryptoCurrency, toCurrency: CryptoCurrency): Boolean { - return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency.network.id) + private fun shouldShowMaxAmount(fromToken: CryptoCurrency?, toCurrency: CryptoCurrency?): Boolean { + return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id) } private fun createTosState(swapProvider: SwapProvider): TosState { @@ -500,72 +523,65 @@ internal class StateBuilder( uiStateHolder: SwapStateHolder, swapProvider: SwapProvider, fromToken: TokenSwapInfo, - toToken: CryptoCurrencyStatus?, - toAccount: Account?, + toSwapCurrencyStatus: SwapCurrencyStatus?, includeFeeInAmount: IncludeFeeInAmount, expressDataError: ExpressDataError, - isReverseSwapPossible: Boolean, needApplyFCARestrictions: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + val fromSwapCurrencyStatus = fromToken.swapCurrencyStatus + val notifications = notificationsFactory.getQuotesErrorStateNotifications( expressDataError = expressDataError, - fromToken = fromToken.cryptoCurrencyStatus.currency, + fromToken = fromSwapCurrencyStatus.currency, feeItem = uiStateHolder.fee, includeFeeInAmount = includeFeeInAmount, ) val providerState = getProviderStateForError( swapProvider = swapProvider, - fromToken = fromToken.cryptoCurrencyStatus.currency, + fromToken = fromSwapCurrencyStatus.currency, expressDataError = expressDataError, onProviderClick = actions.onProviderClick, selectionType = ProviderState.SelectionType.CLICK, needApplyFCARestrictions = needApplyFCARestrictions, ) - val type = TransactionCardType.ReadOnly(accountTitleUM = getToCardAccountTitle(toAccount)) - val receiveCardData = toToken?.let { + val type = TransactionCardType.ReadOnly( + accountTitleUM = getCardAccountTitle( + toSwapCurrencyStatus?.account, + isFromCard = false, + ), + ) + val receiveCardData = toSwapCurrencyStatus?.status?.let { toToken -> SwapCardState.SwapCardData( type = type, amountTextFieldValue = TextFieldValue( text = "0", ), amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - token = toToken, - tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = toToken.currency.network.rawId, - isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.receiveCardData.networkIconRes, + currencyIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), + tokenSymbol = stringReference(toSwapCurrencyStatus.currency.symbol), balance = toToken.getFormattedAmount(isNeedSymbol = false), isBalanceHidden = isBalanceHiddenProvider(), ) } ?: SwapCardState.Empty( type = type, amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO), - amountTextFieldValue = TextFieldValue( - text = "0", - ), - canSelectAnotherToken = true, + amountTextFieldValue = null, ) return uiStateHolder.copy( - sendCardData = uiStateHolder.sendCardData.copy( - amountEquivalent = getFormattedFiatAmount(fromToken.amountFiat), - balance = fromToken.cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), - ), receiveCardData = receiveCardData, notifications = notifications, - permissionState = GiveTxPermissionState.Empty, + permissionUM = SwapPermissionUM.Empty, fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, onClick = actions.onSwapClick, ), - changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = providerState, priceImpact = PriceImpact.Empty, tosState = createTosState(swapProvider), @@ -612,58 +628,32 @@ internal class StateBuilder( } } - @Suppress("LongParameterList") fun createQuotesEmptyAmountState( uiStateHolder: SwapStateHolder, emptyAmountState: SwapState.EmptyAmountState, - fromTokenStatus: CryptoCurrencyStatus, - toTokenStatus: CryptoCurrencyStatus?, - toAccount: Account?, - isReverseSwapPossible: Boolean, + fromSwapCurrencyStatus: SwapCurrencyStatus?, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder return uiStateHolder.copy( - sendCardData = SwapCardState.SwapCardData( - type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable), + sendCardData = uiStateHolder.sendCardData.copy( amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue, amountEquivalent = emptyAmountState.zeroAmountEquivalent, - token = uiStateHolder.sendCardData.token, - tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl, - coinId = uiStateHolder.sendCardData.coinId, - isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.sendCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.sendCardData.networkIconRes, - balance = fromTokenStatus.getFormattedAmount(isNeedSymbol = false), - isBalanceHidden = isBalanceHiddenProvider(), ), - receiveCardData = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly( - accountTitleUM = getToCardAccountTitle(toAccount), - ), + receiveCardData = uiStateHolder.receiveCardData.copy( amountTextFieldValue = TextFieldValue("0"), amountEquivalent = emptyAmountState.zeroAmountEquivalent, - token = uiStateHolder.receiveCardData.token, - tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl, - coinId = uiStateHolder.receiveCardData.coinId, - isNotNativeToken = uiStateHolder.receiveCardData.isNotNativeToken, - tokenCurrency = uiStateHolder.receiveCardData.tokenCurrency, - canSelectAnotherToken = uiStateHolder.receiveCardData.canSelectAnotherToken, - networkIconRes = uiStateHolder.receiveCardData.networkIconRes, - balance = toTokenStatus?.getFormattedAmount(isNeedSymbol = false) ?: DASH_SIGN, - isBalanceHidden = isBalanceHiddenProvider(), ), notifications = persistentListOf(), isInsufficientFunds = false, fee = FeeItemState.Empty, swapButton = SwapButton( - walletInteractionIcon = walletInterationIcon(userWalletProvider()), + walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), isEnabled = false, - isHoldToConfirm = isHoldToConfirmEnabled, + isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, onClick = { }, ), - changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible), + changeCardsButtonState = ChangeCardsButtonState.ENABLED, providerState = ProviderState.Empty(), priceImpact = PriceImpact.Empty, ) @@ -692,15 +682,14 @@ internal class StateBuilder( uiState: SwapStateHolder, amountFormatted: String, amountRaw: String, - fromToken: CryptoCurrency, - fromAccount: Account?, + fromSwapCurrencyStatus: SwapCurrencyStatus, minTxAmount: BigDecimal?, ): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState val amountToSend = amountRaw.toBigDecimalOrNull() val sendInput = if (minTxAmount != null && amountToSend != null && amountToSend < minTxAmount) { val minAmountFormatted = minTxAmount.format { - crypto(cryptoCurrency = fromToken, ignoreSymbolPosition = true) + crypto(cryptoCurrency = fromSwapCurrencyStatus.currency, ignoreSymbolPosition = true) } (uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy( inputError = TransactionCardType.InputError.WrongAmount, @@ -711,7 +700,7 @@ internal class StateBuilder( } else { (uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy( inputError = TransactionCardType.InputError.Empty, - accountTitleUM = getFromCardAccountTitle(fromAccount), + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), ) ?: uiState.sendCardData.type } return uiState.copy( @@ -720,48 +709,46 @@ internal class StateBuilder( text = amountFormatted, selection = TextRange(amountFormatted.length), ), + amountEquivalent = getFormattedFiatAmount( + fromSwapCurrencyStatus.status.value.fiatRate?.let { fiatRate -> + amountToSend?.multiply(fiatRate) + }, + ), type = sendInput, ), ) } - fun updateSendCurrencyBalance( + fun updateCurrencyBalanceStatus( uiState: SwapStateHolder, - cryptoCurrencyStatus: CryptoCurrencyStatus, + fromSwapCurrencyStatus: SwapCurrencyStatus?, + toSwapCurrencyStatus: SwapCurrencyStatus?, + emptyAmountState: SwapState.EmptyAmountState, ): SwapStateHolder { - if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState - return uiState.copy( - sendCardData = uiState.sendCardData.copy( - balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), - token = cryptoCurrencyStatus, + sendCardData = uiState.sendCardData.updateCurrencyStatus( + swapCurrencyStatus = fromSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = true, + shouldResetAmount = false, ), - ) - } - - fun updateReceiveCurrencyBalance( - uiState: SwapStateHolder, - cryptoCurrencyStatus: CryptoCurrencyStatus, - ): SwapStateHolder { - if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState - - return uiState.copy( - receiveCardData = uiState.receiveCardData.copy( - balance = cryptoCurrencyStatus.getFormattedAmount(isNeedSymbol = false), - token = cryptoCurrencyStatus, + receiveCardData = uiState.receiveCardData.updateCurrencyStatus( + swapCurrencyStatus = toSwapCurrencyStatus, + emptyAmountState = emptyAmountState, + isFromCard = false, + shouldResetAmount = false, ), ) } fun updateBalanceHiddenState(uiState: SwapStateHolder, isBalanceHidden: Boolean): SwapStateHolder { - if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState - if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState - val patchedSendCardData = uiState.sendCardData.copy( + val patchedSendCardData = (uiState.sendCardData as? SwapCardState.SwapCardData)?.copy( isBalanceHidden = isBalanceHidden, - ) - val patchedReceiveCardData = uiState.receiveCardData.copy( + ) ?: uiState.sendCardData + + val patchedReceiveCardData = (uiState.receiveCardData as? SwapCardState.SwapCardData)?.copy( isBalanceHidden = isBalanceHidden, - ) + ) ?: uiState.receiveCardData return uiState.copy( sendCardData = patchedSendCardData, @@ -769,32 +756,6 @@ internal class StateBuilder( ) } - fun updateApproveType(uiState: SwapStateHolder, approveType: ApproveType): SwapStateHolder { - val config = uiState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig - val permissionState = (uiState.permissionState as? GiveTxPermissionState.ReadyForRequest)?.copy( - approveType = approveType, - ) ?: uiState.permissionState - return if (config != null) { - uiState.copy( - permissionState = permissionState, - bottomSheetConfig = uiState.bottomSheetConfig.copy( - content = config.copy( - data = config.data.copy(approveType = approveType), - ), - ), - ) - } else { - uiState - } - } - - fun createInitialErrorState(uiState: SwapStateHolder, code: Int, onRefreshClick: () -> Unit): SwapStateHolder { - return uiState.copy( - isInsufficientFunds = false, - notifications = notificationsFactory.getInitialErrorStateNotifications(code, onRefreshClick), - ) - } - private fun createFeeState(txFeeState: TxFeeState, feeType: FeeType): FeeItemState { val isClickable: Boolean val fee = when (txFeeState) { @@ -833,7 +794,6 @@ internal class StateBuilder( isEnabled = false, isInProgress = false, ), - permissionState = GiveTxPermissionState.InProgress, notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications), ) } @@ -847,14 +807,14 @@ internal class StateBuilder( onStatusClick: () -> Unit, txUrl: String, ): SwapStateHolder { - val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) - val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO val toAmount = swapTransactionState.toAmountValue ?: BigDecimal.ZERO val providerState = uiState.providerState as ProviderState.Content - val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount)) - val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount)) + val fromFiatAmount = getFormattedFiatAmount(fromSwapCurrencyStatus.status.value.fiatRate?.multiply(fromAmount)) + val toFiatAmount = getFormattedFiatAmount(toSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount)) val shouldShowStatus = providerState.type == ExchangeProviderType.CEX.providerName return uiState.copy( @@ -869,14 +829,14 @@ internal class StateBuilder( fee = dataState.selectedFee?.let { fee -> stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})") }, - fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount), - toTitle = getToCardAccountTitle(toAccount = dataState.toAccount), + fromTitle = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + toTitle = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()), fromTokenFiatAmount = fromFiatAmount, toTokenFiatAmount = toFiatAmount, - fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency), - toTokenIconState = iconStateConverter.convert(toCryptoCurrency), + fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), onExploreButtonClick = onExploreClick, onStatusButtonClick = onStatusClick, ), @@ -890,14 +850,14 @@ internal class StateBuilder( txUrl: String, onExploreClick: () -> Unit, ): SwapStateHolder { - val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency) - val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency) + val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) + val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus) val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO val toAmount = swapTransactionState.toAmountValue ?: BigDecimal.ZERO val providerState = uiState.providerState as ProviderState.Content - val fromFiatAmount = getFormattedFiatAmount(fromCryptoCurrency.value.fiatRate?.multiply(fromAmount)) - val toFiatAmount = getFormattedFiatAmount(toCryptoCurrency.value.fiatRate?.multiply(toAmount)) + val fromFiatAmount = getFormattedFiatAmount(fromSwapCurrencyStatus.status.value.fiatRate?.multiply(fromAmount)) + val toFiatAmount = getFormattedFiatAmount(toSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount)) return uiState.copy( successState = SwapSuccessStateHolder( @@ -909,14 +869,14 @@ internal class StateBuilder( providerIcon = providerState.iconUrl, rate = providerState.subtitle, fee = TextReference.EMPTY, - fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount), - toTitle = getToCardAccountTitle(toAccount = dataState.toAccount), + fromTitle = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + toTitle = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()), fromTokenFiatAmount = fromFiatAmount, toTokenFiatAmount = toFiatAmount, - fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency), - toTokenIconState = iconStateConverter.convert(toCryptoCurrency), + fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), + toTokenIconState = iconStateConverter.convert(toSwapCurrencyStatus.status), onExploreButtonClick = onExploreClick, onStatusButtonClick = {}, ), @@ -933,74 +893,14 @@ internal class StateBuilder( } @Suppress("LongParameterList") - private fun convertPermissionState( - lastPermissionState: GiveTxPermissionState, - permissionDataState: PermissionDataState, - providerName: String, - onGivePermissionClick: () -> Unit, - onChangeApproveType: (ApproveType) -> Unit, - onOpenLearnMoreAboutApproveClick: () -> Unit, - ): GiveTxPermissionState { - val approveType = if (lastPermissionState is GiveTxPermissionState.ReadyForRequest) { - lastPermissionState.approveType - } else { - ApproveType.UNLIMITED - } + private fun convertPermissionState(permissionDataState: PermissionDataState): SwapPermissionUM { return when (permissionDataState) { - PermissionDataState.Empty -> GiveTxPermissionState.Empty - PermissionDataState.PermissionFailed -> GiveTxPermissionState.Empty - PermissionDataState.PermissionLoading -> GiveTxPermissionState.InProgress - is PermissionDataState.PermissionReadyForRequest -> { - val permissionFee = when (val fee = permissionDataState.requestApproveData.fee) { - TxFeeState.Empty -> error("Fee shouldn't be empty") - is TxFeeState.MultipleFeeState -> fee.priorityFee - is TxFeeState.SingleFeeState -> fee.fee - } - GiveTxPermissionState.ReadyForRequest( - currency = permissionDataState.currency, - amount = permissionDataState.amount, - approveType = approveType, - walletAddress = getShortAddressValue(permissionDataState.walletAddress), - spenderAddress = getShortAddressValue(permissionDataState.spenderAddress), - fee = TextReference.Str("${permissionFee.feeCryptoFormatted} (${permissionFee.feeFiatFormatted})"), - approveButton = ApprovePermissionButton( - isEnabled = true, - onClick = onGivePermissionClick, - ), - cancelButton = CancelPermissionButton( - enabled = true, - ), - onChangeApproveType = onChangeApproveType, - subtitle = resourceReference( - id = R.string.give_permission_swap_subtitle, - formatArgs = wrappedList(providerName, permissionDataState.currency), - ), - dialogText = resourceReference(R.string.swapping_approve_information_text), - footerText = resourceReference(R.string.swap_give_permission_fee_footer), - onOpenLearnMoreAboutApproveClick = onOpenLearnMoreAboutApproveClick, - isResetApproval = permissionDataState.isResetApproval, - ) - } - } - } - - fun showPermissionBottomSheet(uiState: SwapStateHolder, onDismiss: () -> Unit): SwapStateHolder { - val permissionState = uiState.permissionState - if (permissionState is GiveTxPermissionState.ReadyForRequest) { - val config = GiveTxPermissionBottomSheetConfig( - data = permissionState, - onCancel = onDismiss, - walletInteractionIcon = walletInterationIcon(userWalletProvider()), - ) - return uiState.copy( - bottomSheetConfig = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = onDismiss, - content = config, - ), + is PermissionDataState.PermissionRequired -> SwapPermissionUM.PermissionRequired( + isResetApproval = permissionDataState.isResetApproval, + spenderAddress = permissionDataState.spenderAddress, ) + else -> SwapPermissionUM.Empty } - return uiState } fun dismissBottomSheet(uiState: SwapStateHolder): SwapStateHolder { @@ -1061,7 +961,7 @@ internal class StateBuilder( val tokenInfo = tokenSwapInfoForProviders[providerState.id] if (providerState is ProviderState.Content && tokenInfo != null) { val rateString = tokenInfo.tokenAmount - .getFormattedCryptoAmount(tokenInfo.cryptoCurrencyStatus.currency) + .getFormattedCryptoAmount(tokenInfo.swapCurrencyStatus.currency) providerState.copy( subtitle = stringReference(rateString), percentLowerThenBest = pricesLowerBest[providerState.id]?.let { percent -> @@ -1095,7 +995,7 @@ internal class StateBuilder( } actions.onSelectFeeType.invoke(selectedItem) }, - readMoreUrl = buildReadMoreUrl(), + readMoreUrl = FEE_BLOG_LINK, feeItems = txFeeState.toFeeItemState(), readMore = resourceReference(R.string.common_read_more), onReadMoreClick = actions.onLinkClick, @@ -1109,15 +1009,6 @@ internal class StateBuilder( ) } - @Deprecated("Use TangemBlockUrlBuilder instead") - private fun buildReadMoreUrl(): String { - return buildString { - append(FEE_READ_MORE_URL_FIRST_PART) - append(getLocaleName()) - append(FEE_READ_MORE_URL_SECOND_PART) - } - } - private fun TxFeeState.MultipleFeeState.toFeeItemState(): ImmutableList { return listOf( FeeItemState.Content( @@ -1160,7 +1051,7 @@ internal class StateBuilder( } is SwapState.SwapError -> getProviderStateForError( swapProvider = provider, - fromToken = state.fromTokenInfo.cryptoCurrencyStatus.currency, + fromToken = state.fromTokenInfo.swapCurrencyStatus.currency, expressDataError = state.error, onProviderClick = onProviderSelect, selectionType = ProviderState.SelectionType.SELECT, @@ -1169,16 +1060,6 @@ internal class StateBuilder( } } - private fun getShortAddressValue(fullAddress: String): String { - check(fullAddress.length > ADDRESS_MIN_LENGTH) { "Invalid address" } - val firstAddressPart = fullAddress.substring(startIndex = 0, endIndex = ADDRESS_FIRST_PART_LENGTH) - val secondAddressPart = fullAddress.substring( - startIndex = fullAddress.length - ADDRESS_SECOND_PART_LENGTH, - endIndex = fullAddress.length, - ) - return "$firstAddressPart...$secondAddressPart" - } - @Suppress("LongParameterList") private fun SwapProvider.convertToContentClickableProviderState( isBestRate: Boolean, @@ -1192,18 +1073,18 @@ internal class StateBuilder( ): ProviderState { val rate = toTokenInfo.tokenAmount.value.calculateRate( fromTokenInfo.tokenAmount.value, - toTokenInfo.cryptoCurrencyStatus.currency.decimals, + toTokenInfo.swapCurrencyStatus.currency.decimals, ) - val fromCurrencySymbol = fromTokenInfo.cryptoCurrencyStatus.currency.symbol + val fromCurrencySymbol = fromTokenInfo.swapCurrencyStatus.currency.symbol val rateString = buildString { append(BigDecimal.ONE.format { crypto(symbol = fromCurrencySymbol, decimals = 0).anyDecimals() }) append(" ≈ ") - append(rate.format { crypto(toTokenInfo.cryptoCurrencyStatus.currency) }) + append(rate.format { crypto(toTokenInfo.swapCurrencyStatus.currency) }) } val additionalBadge = when { needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList - permissionState is PermissionDataState.PermissionReadyForRequest -> + permissionState is PermissionDataState.PermissionRequired -> ProviderState.AdditionalBadge.PermissionRequired isRecommended -> ProviderState.AdditionalBadge.Recommended isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions -> ProviderState.AdditionalBadge.BestTrade @@ -1232,11 +1113,11 @@ internal class StateBuilder( needApplyFCARestrictions: Boolean, ): ProviderState { val toTokenInfo = state.toTokenInfo - val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.cryptoCurrencyStatus.currency) + val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.swapCurrencyStatus.currency) val additionalBadge = when { needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList - state.permissionState is PermissionDataState.PermissionReadyForRequest -> { + state.permissionState is PermissionDataState.PermissionRequired -> { ProviderState.AdditionalBadge.PermissionRequired } isRecommended -> ProviderState.AdditionalBadge.Recommended @@ -1286,8 +1167,8 @@ internal class StateBuilder( ) } - private fun CryptoCurrencyStatus.getFormattedAmount(isNeedSymbol: Boolean): String { - val amount = value.amount ?: return DASH_SIGN + private fun CryptoCurrencyStatus?.getFormattedAmount(isNeedSymbol: Boolean): String { + val amount = this?.value?.amount ?: return DASH_SIGN val symbol = if (isNeedSymbol) currency.symbol else "" return amount.format { crypto(symbol, currency.decimals) } } @@ -1314,14 +1195,6 @@ internal class StateBuilder( return this.divide(to, min(rateDecimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP) } - private fun getLocaleName(): String { - return if (Locale.getDefault().language == "ru") { - RU_LOCALE - } else { - EN_LOCALE - } - } - private fun String.appendApproximateSign(): String { return "$TILDE_SIGN $this" } @@ -1330,27 +1203,20 @@ internal class StateBuilder( return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId) } - private fun getFromCardAccountTitle(fromAccount: Account?): AccountTitleUM { - return if (fromAccount != null && isAccountsModeProvider()) { - AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_from), - name = fromAccount.accountName.toUM().value, - icon = fromAccount.toIconUM(), - ) + private fun getCardAccountTitle(account: Account?, isFromCard: Boolean): AccountTitleUM { + val (prefix, placeholder) = if (isFromCard) { + R.string.common_from to R.string.swapping_from_title } else { - AccountTitleUM.Text(resourceReference(R.string.swapping_from_title)) + R.string.common_to to R.string.swapping_to_title } - } - - private fun getToCardAccountTitle(toAccount: Account?): AccountTitleUM { - return if (toAccount != null && isAccountsModeProvider()) { + return if (account != null && isAccountsModeProvider()) { AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_to), - name = toAccount.accountName.toUM().value, - icon = toAccount.toIconUM(), + prefixText = resourceReference(prefix), + name = account.accountName.toUM().value, + icon = account.toIconUM(), ) } else { - AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)) + AccountTitleUM.Text(resourceReference(placeholder)) } } @@ -1361,22 +1227,9 @@ internal class StateBuilder( } } - private fun getChangeCardsButtonState(isReverseSwapPossible: Boolean) = if (isReverseSwapPossible) { - ChangeCardsButtonState.ENABLED - } else { - ChangeCardsButtonState.DISABLED - } - private companion object { - private const val RU_LOCALE = "ru" - private const val EN_LOCALE = "en" - const val ADDRESS_MIN_LENGTH = 11 - const val ADDRESS_FIRST_PART_LENGTH = 7 - const val ADDRESS_SECOND_PART_LENGTH = 4 private const val MAX_DECIMALS_TO_SHOW = 8 private const val IF_ZERO_DECIMALS_TO_SHOW = 2 - private const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/" - private const val FEE_READ_MORE_URL_SECOND_PART = "/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/" private val FCA_RESTRICTED_PROVIDER_IDS = setOf( "changelly", diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt index 0a5f77d83d..593a6e4109 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreen.kt @@ -9,8 +9,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag -import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig import com.tangem.core.ui.components.appbar.AppBarWithBackButton import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -44,7 +42,7 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: feeBlock = if (feeSelectorBlockComponent != null) { @Composable { modifier: Modifier -> feeSelectorBlockComponent.Content( - modifier = Modifier + modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action), ) @@ -61,7 +59,6 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: val config = stateHolder.bottomSheetConfig when (config.content) { - is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(config = config) is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(config = config) is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(config = config) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 6716c863b8..322b60f85a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -24,25 +24,20 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState -import com.tangem.common.ui.extensions.iconResId import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.* import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SwapTokenScreenTestTags -import com.tangem.domain.models.network.Network import com.tangem.feature.swap.domain.models.ui.FeeType import com.tangem.feature.swap.domain.models.ui.PriceImpact import com.tangem.feature.swap.models.* @@ -50,6 +45,8 @@ 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.presentation.R +import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.receiveCard +import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview.sendCard import kotlinx.collections.immutable.persistentListOf @Suppress("LongMethod") @@ -132,22 +129,22 @@ private fun MainInfo(state: SwapStateHolder) { ) { val (topCard, bottomCard, button) = createRefs() val priceImpact = state.priceImpact - TransactionCardData( + TransactionCard( priceImpact = priceImpact, swapCardState = state.sendCardData, modifier = Modifier.constrainAs(topCard) { top.linkTo(parent.top) }, - onSelectTokenClick = state.onSelectTokenClick, + onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.FROM) }, ) val marginCard = TangemTheme.dimens.spacing12 - TransactionCardData( + TransactionCard( priceImpact = priceImpact, swapCardState = state.receiveCardData, modifier = Modifier.constrainAs(bottomCard) { top.linkTo(topCard.bottom, margin = marginCard) }, - onSelectTokenClick = state.onSelectTokenClick, + onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.TO) }, ) val marginButton = TangemTheme.dimens.spacing30 SwapButton( @@ -161,43 +158,6 @@ private fun MainInfo(state: SwapStateHolder) { } } -@Composable -private fun TransactionCardData( - priceImpact: PriceImpact, - swapCardState: SwapCardState, - onSelectTokenClick: (() -> Unit)?, - modifier: Modifier = Modifier, -) { - when (swapCardState) { - is SwapCardState.Empty -> { - TransactionCardEmpty( - type = swapCardState.type, - amountEquivalent = swapCardState.amountEquivalent, - textFieldValue = swapCardState.amountTextFieldValue, - onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, - modifier = modifier, - ) - } - is SwapCardState.SwapCardData -> { - TransactionCard( - type = swapCardState.type, - balance = swapCardState.balance.orMaskWithStars(swapCardState.isBalanceHidden), - textFieldValue = swapCardState.amountTextFieldValue, - amountEquivalent = swapCardState.amountEquivalent, - tokenIconUrl = swapCardState.tokenIconUrl.orEmpty(), - tokenCurrency = swapCardState.tokenCurrency, - priceImpact = priceImpact, - networkIconRes = if (swapCardState.isNotNativeToken) swapCardState.networkIconRes else null, - iconPlaceholder = swapCardState.coinId?.let { - Network.RawID(it).iconResId - }, - onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null, - modifier = modifier, - ) - } - } -} - @Composable private fun ProviderTos(tosState: TosState, modifier: Modifier = Modifier) { val tos = tosState.tosLink @@ -410,41 +370,6 @@ private fun MainButton(state: SwapStateHolder) { // region preview -private val sendCard = SwapCardState.SwapCardData( - type = TransactionCardType.Inputtable( - onAmountChanged = {}, - onFocusChanged = {}, - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = null, - ), - amountTextFieldValue = TextFieldValue(), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - isNotNativeToken = true, - canSelectAnotherToken = false, - balance = "123", - coinId = "", - token = null, - networkIconRes = R.drawable.img_polygon_22, - isBalanceHidden = false, -) - -private val receiveCard = SwapCardState.SwapCardData( - type = TransactionCardType.ReadOnly(), - amountTextFieldValue = TextFieldValue(), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - isNotNativeToken = true, - canSelectAnotherToken = true, - balance = "33333", - coinId = "", - token = null, - networkIconRes = R.drawable.img_polygon_22, - isBalanceHidden = false, -) - private val state = SwapStateHolder( sendCardData = sendCard, receiveCardData = receiveCard, @@ -469,14 +394,13 @@ private val state = SwapStateHolder( onRefresh = {}, onBackClicked = {}, onChangeCardsClicked = {}, - permissionState = GiveTxPermissionState.InProgress, - blockchainId = "POLYGON", + permissionUM = SwapPermissionUM.Empty, providerState = ProviderState.Loading(), priceImpact = PriceImpact.Empty, shouldShowMaxAmount = true, isInsufficientFunds = false, onSuccess = {}, - onSelectTokenClick = {}, + onSelectTokenClick = { _ -> }, tosState = TosState( tosLink = LegalState( title = stringReference("Terms of Use"), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 1886f3effd..0b5497b495 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -1,84 +1,99 @@ package com.tangem.feature.swap.ui -import androidx.annotation.DrawableRes +import android.content.res.Configuration import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.ripple -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.SubcomposeAsyncImage -import coil.request.ImageRequest -import com.tangem.common.ui.account.AccountNameUM import com.tangem.common.ui.account.AccountTitle -import com.tangem.common.ui.account.AccountTitleUM -import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.core.ui.R import com.tangem.core.ui.components.* +import com.tangem.core.ui.components.buttons.SecondarySmallButton +import com.tangem.core.ui.components.buttons.SmallButtonConfig +import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SwapTokenScreenTestTags -import com.tangem.core.ui.utils.ImageBackgroundContrastChecker -import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.models.SwapCardState import com.tangem.feature.swap.models.TransactionCardType -import kotlinx.coroutines.launch +import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview -@Suppress("LongParameterList") @Composable -fun TransactionCard( - type: TransactionCardType, - balance: String, - tokenIconUrl: String, - tokenCurrency: String, - amountEquivalent: TextReference?, +internal fun TransactionCard( priceImpact: PriceImpact, - textFieldValue: TextFieldValue?, + swapCardState: SwapCardState, + onSelectTokenClick: () -> Unit, modifier: Modifier = Modifier, - @DrawableRes iconPlaceholder: Int? = null, - @DrawableRes networkIconRes: Int? = null, - onChangeTokenClick: (() -> Unit)? = null, ) { - val cardTag = when (type) { + val cardTag = when (swapCardState.type) { is TransactionCardType.Inputtable -> SwapTokenScreenTestTags.SWAP_CARD is TransactionCardType.ReadOnly -> SwapTokenScreenTestTags.RECEIVE_CARD } + when (swapCardState) { + is SwapCardState.Empty -> { + TransactionCardEmpty( + cardState = swapCardState, + onChangeTokenClick = onSelectTokenClick, + modifier = modifier.testTag(cardTag), + ) + } + is SwapCardState.SwapCardData -> { + TransactionCardData( + cardState = swapCardState, + priceImpact = priceImpact, + onChangeTokenClick = onSelectTokenClick, + modifier = modifier.testTag(cardTag), + ) + } + is SwapCardState.Loading -> TransactionCardLoading( + modifier = modifier.testTag(cardTag), + ) + } +} + +@Composable +private fun TransactionCardData( + cardState: SwapCardState.SwapCardData, + priceImpact: PriceImpact, + modifier: Modifier = Modifier, + onChangeTokenClick: (() -> Unit)? = null, +) { Box( modifier = modifier .background( shape = RoundedCornerShape(TangemTheme.dimens.radius16), color = TangemTheme.colors.background.primary, ) - .fillMaxSize() - .testTag(cardTag), + .fillMaxWidth(), ) { Column( modifier = Modifier @@ -86,22 +101,26 @@ fun TransactionCard( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start, ) { - Header(balance = stringResourceSafe(R.string.common_balance, balance), type = type) + Header( + balance = stringResourceSafe( + R.string.common_balance, + cardState.balance, + ).orMaskWithStars(cardState.isBalanceHidden), + type = cardState.type, + ) Content( - type = type, - amountEquivalent = amountEquivalent, - textFieldValue = textFieldValue, + type = cardState.type, + amountEquivalent = cardState.amountEquivalent, + textFieldValue = cardState.amountTextFieldValue, priceImpact = priceImpact, ) } Box(modifier = Modifier.align(Alignment.BottomEnd)) { Token( - tokenIconUrl = tokenIconUrl, - tokenCurrency = tokenCurrency, - networkIconRes = networkIconRes, - iconPlaceholder = iconPlaceholder, + currencyIconState = cardState.currencyIconState, + tokenSymbol = cardState.tokenSymbol, ) } @@ -124,61 +143,134 @@ fun TransactionCard( } @Composable -fun TransactionCardEmpty( - type: TransactionCardType, - amountEquivalent: TextReference?, - textFieldValue: TextFieldValue?, +private fun TransactionCardEmpty( + cardState: SwapCardState.Empty, modifier: Modifier = Modifier, - onChangeTokenClick: (() -> Unit)? = null, + onChangeTokenClick: () -> Unit, ) { - Box( + Column( modifier = modifier .background( shape = RoundedCornerShape(TangemTheme.dimens.radius12), color = TangemTheme.colors.background.primary, ) - .fillMaxSize(), + .padding( + top = 12.dp, + start = 12.dp, + end = 12.dp, + bottom = 16.dp, + ) + .fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start, + AccountTitle( + accountTitleUM = cardState.type.accountTitleUM, + modifier = Modifier.fillMaxWidth(), + ) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Header( - balance = stringResourceSafe(id = R.string.swapping_token_not_available), - type = type, - ) - - Content( - type = type, - amountEquivalent = amountEquivalent, - textFieldValue = textFieldValue, - priceImpact = PriceImpact.Empty, - ) - } - - Box(modifier = Modifier.align(Alignment.BottomEnd)) { - Token( - tokenIconUrl = "", - tokenCurrency = "", - iconPlaceholder = R.drawable.ic_no_token_44, - ) - } - - if (onChangeTokenClick != null) { - Box(modifier = Modifier.align(Alignment.CenterEnd)) { - ChangeTokenSelector() + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = cardState.amountTextFieldValue?.text.orEmpty(), + color = TangemTheme.colors.text.disabled, + style = TangemTheme.typography.h2, + autoSize = TextAutoSize.StepBased( + minFontSize = 16.sp, + maxFontSize = TangemTheme.typography.h2.fontSize, + ), + maxLines = 1, + modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD), + ) + Text( + text = cardState.amountEquivalent.resolveAnnotatedReference(), + color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography.body2, + modifier = Modifier + .defaultMinSize(minHeight = TangemTheme.dimens.size20) + .testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT), + ) } - Box( - Modifier - .align(Alignment.CenterEnd) - .height(TangemTheme.dimens.size116) - .width(TangemTheme.dimens.size102) - .clickable( - indication = ripple(bounded = false), - interactionSource = remember { MutableInteractionSource() }, - ) { onChangeTokenClick() }, + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.common_choose_token), + icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24), + onClick = onChangeTokenClick, + ), + ) + } + } +} + +@Composable +private fun TransactionCardLoading(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .background( + shape = RoundedCornerShape(TangemTheme.dimens.radius12), + color = TangemTheme.colors.background.primary, + ) + .padding( + top = 12.dp, + start = 12.dp, + end = 12.dp, + bottom = 16.dp, + ) + .fillMaxWidth(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + TextShimmer( + text = stringResourceSafe(R.string.swapping_to_title), + style = TangemTheme.typography.subtitle2, + ) + TextShimmer( + style = TangemTheme.typography.body2, + modifier = Modifier + .testTag(SwapTokenScreenTestTags.BALANCE) + .width(60.dp), + ) + } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextShimmer( + style = TangemTheme.typography.h2, + modifier = Modifier + .width(100.dp) + .testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD), + ) + TextShimmer( + style = TangemTheme.typography.body2, + modifier = Modifier + .defaultMinSize( + minHeight = 20.dp, + minWidth = 40.dp, + ) + .testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT), + ) + } + SecondarySmallButton( + config = SmallButtonConfig( + text = resourceReference(R.string.common_choose_token), + icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24), + isEnabled = false, + onClick = {}, + ), ) } } @@ -376,12 +468,7 @@ private fun Content( @Suppress("MagicNumber") @Composable -fun Token( - tokenIconUrl: String, - tokenCurrency: String, - @DrawableRes iconPlaceholder: Int? = null, - @DrawableRes networkIconRes: Int? = null, -) { +fun Token(currencyIconState: CurrencyIconState, tokenSymbol: TextReference) { Column( modifier = Modifier .padding( @@ -392,15 +479,13 @@ fun Token( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.End, ) { - TokenIcon( - tokenIconUrl = tokenIconUrl, - tokenCurrency = tokenCurrency, - iconPlaceholder = iconPlaceholder, - networkIconRes = networkIconRes, + CurrencyIcon( + state = currencyIconState, + modifier = Modifier.padding(end = TangemTheme.dimens.spacing16), ) SpacerH4() Text( - text = tokenCurrency, + text = tokenSymbol.resolveReference(), color = TangemTheme.colors.text.primary1, maxLines = 1, style = TangemTheme.typography.subtitle2, @@ -412,89 +497,10 @@ fun Token( } } -@Suppress("NullableToStringCall") -@Composable -private fun TokenIcon( - tokenIconUrl: String, - tokenCurrency: String, - @DrawableRes iconPlaceholder: Int? = null, - @DrawableRes networkIconRes: Int? = null, -) { - var iconBackgroundColor by remember { mutableStateOf(Color.Transparent) } - var isBackgroundColorDefined by remember { mutableStateOf(false) } - val itemBackgroundColor = TangemTheme.colors.background.primary.toArgb() - val isDarkTheme = isSystemInDarkTheme() - val coroutineScope = rememberCoroutineScope() - - Box( - modifier = Modifier - .padding(end = TangemTheme.dimens.spacing16) - .size(TangemTheme.dimens.size42) - .testTag(SwapTokenScreenTestTags.TOKEN_ICON), - ) { - val tokenImageModifier = Modifier - .align(Alignment.BottomStart) - .size(TangemTheme.dimens.size36) - .background( - color = iconBackgroundColor, - shape = TangemTheme.shapes.roundedCorners8, - ) - .clip(TangemTheme.shapes.roundedCorners8) - - val data = tokenIconUrl.ifEmpty { iconPlaceholder } - - val pixelsSize = with(LocalDensity.current) { TangemTheme.dimens.size36.roundToPx() } - - SubcomposeAsyncImage( - modifier = tokenImageModifier, - model = ImageRequest.Builder(LocalContext.current) - .data(data) - .size(size = pixelsSize) - .memoryCacheKey(key = data.toString() + pixelsSize) - .crossfade(true) - .allowHardware(false) - .listener( - onSuccess = { _, result -> - if (isDarkTheme) { - coroutineScope.launch { - val color = ImageBackgroundContrastChecker( - drawable = result.drawable, - backgroundColor = itemBackgroundColor, - size = pixelsSize, - ).getContrastColor(true) - iconBackgroundColor = color - isBackgroundColorDefined = true - } - } - }, - ).build(), - loading = { CircleShimmer(modifier = tokenImageModifier) }, - contentDescription = tokenCurrency, - ) - - if (networkIconRes != null) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .size(TangemTheme.dimens.size18) - .background(color = TangemTheme.colors.background.primary, shape = CircleShape), - contentAlignment = Alignment.Center, - ) { - Image( - modifier = Modifier.padding(all = TangemTheme.dimens.spacing2), - painter = painterResource(id = networkIconRes), - contentDescription = null, - ) - } - } - } -} - @Composable fun ChangeTokenSelector() { Box( modifier = Modifier - .fillMaxHeight() .padding( top = TangemTheme.dimens.spacing12, start = TangemTheme.dimens.spacing24, @@ -513,129 +519,29 @@ fun ChangeTokenSelector() { } } -// region preview - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) +// region Preview @Composable -private fun Preview_TransactionCard_InLightTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreview() +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun TransactionCard_Preview(@PreviewParameter(PreviewProvider::class) params: SwapCardState) { + TangemThemePreview { + TransactionCard( + priceImpact = PriceImpact.Empty, + swapCardState = params, + onSelectTokenClick = {}, + modifier = Modifier, + ) } } -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithPriceImpact_InLightTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithPriceImpact() - } +private class PreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + SwapTransactionCardPreview.sendCard, + SwapTransactionCardPreview.receiveCard, + SwapTransactionCardPreview.emptyReadOnlyCard, + SwapTransactionCardPreview.emptyInputtableCard, + SwapTransactionCardPreview.loadingCard, + ) } - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithoutPriceImpact_InLightTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithoutPriceImpact() - } -} - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCard_InDarkTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreview() - } -} - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithPriceImpact_InDarkTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithPriceImpact() - } -} - -@Preview(widthDp = 328, heightDp = 116, showBackground = true) -@Composable -private fun Preview_TransactionCardWithoutPriceImpact_InDarkTheme() { - TangemThemePreview(isDark = false) { - TransactionCardPreviewWithoutPriceImpact() - } -} - -@Composable -private fun TransactionCardPreview() { - TransactionCard( - type = TransactionCardType.Inputtable( - onAmountChanged = {}, - onFocusChanged = {}, - inputError = TransactionCardType.InputError.Empty, - accountTitleUM = null, - ), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - networkIconRes = R.drawable.img_polygon_22, - onChangeTokenClick = {}, - balance = "123", - textFieldValue = TextFieldValue(), - priceImpact = PriceImpact.Empty, - ) -} - -@Composable -@Suppress("MagicNumber") -private fun TransactionCardPreviewWithPriceImpact() { - TransactionCard( - type = TransactionCardType.ReadOnly( - shouldShowWarning = true, - accountTitleUM = AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_from), - name = AccountNameUM.DefaultMain.value, - icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), - ), - ), - amountEquivalent = combinedReference( - stringReference("1 000 000 $"), - styledStringReference( - " (-15%)", - { SpanStyle(color = TangemTheme.colors.text.attention) }, - ), - ), - tokenIconUrl = "", - tokenCurrency = "DAI", - networkIconRes = R.drawable.img_polygon_22, - onChangeTokenClick = {}, - balance = "123", - textFieldValue = TextFieldValue("1000000.0000000000000000000000000"), - priceImpact = PriceImpact( - value = 0.15F.toBigDecimal(), - type = PriceImpact.Type.MEDIUM, - amountSignificance = PriceImpact.AmountSignificance.HIGH, - ), - ) -} - -@Composable -@Suppress("MagicNumber") -private fun TransactionCardPreviewWithoutPriceImpact() { - TransactionCard( - type = TransactionCardType.ReadOnly( - accountTitleUM = AccountTitleUM.Account( - prefixText = resourceReference(R.string.common_from), - name = AccountNameUM.DefaultMain.value, - icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), - ), - ), - amountEquivalent = stringReference("1 000 000"), - tokenIconUrl = "", - tokenCurrency = "DAI", - networkIconRes = R.drawable.img_polygon_22, - onChangeTokenClick = {}, - balance = "123", - textFieldValue = TextFieldValue(), - priceImpact = PriceImpact.Empty, - ) -} - -// endregion preview \ No newline at end of file +// endregion \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt new file mode 100644 index 0000000000..04fc89a1a1 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt @@ -0,0 +1,79 @@ +package com.tangem.feature.swap.ui.preview + +import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.ui.account.AccountNameUM +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.CryptoPortfolioIconConverter +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.feature.swap.models.SwapCardState +import com.tangem.feature.swap.models.TransactionCardType +import com.tangem.feature.swap.presentation.R + +internal object SwapTransactionCardPreview { + + val sendCard = SwapCardState.SwapCardData( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Account( + prefixText = stringReference("From"), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), + ), + ), + amountTextFieldValue = TextFieldValue(), + amountEquivalent = stringReference("1 000 000"), + currencyIconState = CurrencyIconState.Loading, + tokenSymbol = stringReference("DAI"), + balance = "123", + isBalanceHidden = false, + ) + + val receiveCard = SwapCardState.SwapCardData( + type = TransactionCardType.ReadOnly( + accountTitleUM = AccountTitleUM.Account( + prefixText = stringReference("To"), + name = AccountNameUM.DefaultMain.value, + icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), + ), + ), + amountTextFieldValue = TextFieldValue(), + amountEquivalent = stringReference("1 000 000"), + currencyIconState = CurrencyIconState.Loading, + tokenSymbol = stringReference("DAI"), + balance = "33333", + isBalanceHidden = false, + ) + + val emptyReadOnlyCard = SwapCardState.Empty( + type = TransactionCardType.ReadOnly( + accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)), + ), + amountEquivalent = stringReference("$0.00"), + amountTextFieldValue = null, + ) + + val emptyInputtableCard = SwapCardState.Empty( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)), + ), + amountEquivalent = stringReference("$0.00"), + amountTextFieldValue = null, + ) + + val loadingCard = SwapCardState.Loading( + type = TransactionCardType.Inputtable( + onAmountChanged = {}, + onFocusChanged = {}, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)), + ), + ) +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt new file mode 100644 index 0000000000..82ce65c84e --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt @@ -0,0 +1,867 @@ +package com.tangem.feature.swap + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.core.lce.Lce +import com.tangem.domain.exchange.RampStateManager +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.TokensGroupType +import com.tangem.domain.models.TokensSortType +import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.* +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.quote.PriceChange +import com.tangem.domain.models.tokenlist.TokenList +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.feature.swap.model.InitialCurrenciesResolver +import com.tangem.features.swap.SwapComponent.Params.CurrencyPosition +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +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 + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultInitialCurrenciesResolverTest { + + private val getUserWalletUseCase = mockk() + private val singleAccountStatusListSupplier = mockk() + private val rampStateManager = mockk() + + private val userWalletId = UserWalletId("0011") + private val userWallet = mockk { + every { walletId } returns userWalletId + } + + private val resolver = InitialCurrenciesResolver( + getUserWalletUseCase = getUserWalletUseCase, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + rampStateManager = rampStateManager, + ) + + private var uniqueIndex = 0 + + @BeforeEach + fun setup() { + coEvery { getUserWalletUseCase(userWalletId) } returns userWallet.right() + } + + // region no initial currency + + @Test + fun `GIVEN available tokens with balance WHEN no initial currency THEN returns available token with max fiat balance`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + val currency3 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100")) + val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("300")) + val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("500")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2, status3)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to true, currency2 to true, currency3 to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status2) + assertThat(to).isNull() + } + + @Test + fun `GIVEN available tokens without balance WHEN no initial currency THEN returns first token from first account`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO) + val status2 = createCurrencyStatus(currency2, fiatAmount = null) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to true, currency2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN no available tokens with balance WHEN no initial currency THEN returns token with max fiat balance`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("100")) + val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to false, currency2 to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status2) + assertThat(to).isNull() + } + + @Test + fun `GIVEN no available tokens without balance WHEN no initial currency THEN returns first token from first account`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal.ZERO) + val status2 = createCurrencyStatus(currency2, fiatAmount = null) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status1, status2)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(currency1 to false, currency2 to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN empty accounts WHEN no initial currency THEN returns null pair`() = runTest { + setupSupplier(emptyList()) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to).isNull() + } + + @Test + fun `GIVEN multiple accounts WHEN fallback to first THEN returns first token from first account`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = null) + val status2 = createCurrencyStatus(currency2, fiatAmount = null) + + val account1Status = createCryptoPortfolioAccountStatus(listOf(status1)) + val account2Status = createCryptoPortfolioAccountStatus(listOf(status2)) + setupSupplier(listOf(account1Status, account2Status)) + + setupAvailability(linkedMapOf(currency1 to true)) + setupAvailability(linkedMapOf(currency2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN mixed availability and balance across accounts WHEN no initial currency THEN returns best available with balance`() = + runTest { + val currency1 = mockCryptoCurrency() + val currency2 = mockCryptoCurrency() + val currency3 = mockCryptoCurrency() + + val status1 = createCurrencyStatus(currency1, fiatAmount = BigDecimal("50")) + val status2 = createCurrencyStatus(currency2, fiatAmount = BigDecimal("200")) + val status3 = createCurrencyStatus(currency3, fiatAmount = BigDecimal("100")) + + val account1Status = createCryptoPortfolioAccountStatus(listOf(status1)) + val account2Status = createCryptoPortfolioAccountStatus(listOf(status2, status3)) + setupSupplier(listOf(account1Status, account2Status)) + + setupAvailability(linkedMapOf(currency1 to true)) + setupAvailability(linkedMapOf(currency2 to false, currency3 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status3) + assertThat(to).isNull() + } + + // endregion + + // region initial currency tests + + @Test + fun `GIVEN initial currency not found WHEN invoke THEN returns null pair`() = runTest { + val initialCurrency = mockCryptoCurrency() + val otherCurrency = mockCryptoCurrency() + + val status = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(otherCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency available with balance WHEN invoke THEN returns it as from`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency available without balance WHEN invoke THEN returns it as to and best as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to true, otherCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency available without balance and is only token WHEN invoke THEN returns it as to and from is null`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal.ZERO) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + @Test + fun `GIVEN initial currency not available with balance WHEN invoke THEN returns it as from`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency not available with balance and other available with higher balance WHEN invoke THEN returns initial as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(initialStatus) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial currency not available without balance and other available with balance WHEN invoke THEN returns best available as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val other1 = mockCryptoCurrency() + val other2 = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val other1Status = createCurrencyStatus(other1, fiatAmount = BigDecimal("100")) + val other2Status = createCurrencyStatus(other2, fiatAmount = BigDecimal("300")) + val accountStatus = createCryptoPortfolioAccountStatus( + listOf(initialStatus, other1Status, other2Status), + ) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to false, other1 to true, other2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(other2Status) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and other available without balance WHEN invoke THEN returns first token as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(otherCurrency to true, accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and no available tokens with balance WHEN invoke THEN returns best by balance as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("200")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(initialStatus, otherStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(accountCurrency to false, otherCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and no available tokens without balance WHEN invoke THEN returns first token as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + val otherCurrency = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(accountCurrency, fiatAmount = null) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal.ZERO) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, initialStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(otherCurrency to false, accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(otherStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial currency not available without balance and is only token WHEN invoke THEN returns it as to and from is null`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = null) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + @Test + fun `GIVEN initial from second account without balance and same token in main with balance WHEN invoke THEN does not pick same token as from`() = + runTest { + val sharedNetworkId = "ethereum" + val sharedContractAddress = "0xUSDT" + + // Same token (same network + contract), different ids (simulates different derivations) + val idInSecondary = mockCurrencyId(sharedNetworkId, sharedContractAddress) + val idInMain = mockCurrencyId(sharedNetworkId, sharedContractAddress) + val initialCurrency = mockCryptoCurrency(id = idInSecondary) + val usdtInSecondary = mockCryptoCurrency(id = idInSecondary) + val usdtInMain = mockCryptoCurrency(id = idInMain) + + val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = null) + val statusInMain = createCurrencyStatus(usdtInMain, fiatAmount = BigDecimal("1000")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(statusInMain, statusInSecondary)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(usdtInMain to true, usdtInSecondary to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // Selected goes to TO; FROM must not be the same token from another account + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(statusInSecondary) + } + + @Test + fun `GIVEN initial from second account with balance WHEN invoke THEN returns it as from`() = runTest { + val idInSecondary = mockCurrencyId("ethereum", "0xUSDT") + val initialCurrency = mockCryptoCurrency(id = idInSecondary) + val usdtInSecondary = mockCryptoCurrency(id = idInSecondary) + val otherCurrency = mockCryptoCurrency() + + val statusInSecondary = createCurrencyStatus(usdtInSecondary, fiatAmount = BigDecimal("200")) + val otherStatus = createCurrencyStatus(otherCurrency, fiatAmount = BigDecimal("500")) + + val accountStatus = createCryptoPortfolioAccountStatus(listOf(otherStatus, statusInSecondary)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(otherCurrency to true, usdtInSecondary to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(statusInSecondary) + assertThat(to).isNull() + } + + @Test + fun `GIVEN initial in secondary account placed in TO WHEN invoke THEN FROM is picked only from same account`() = + runTest { + // Main account has a high-balance available currency. + val mainOnlyCurrency = mockCryptoCurrency() + val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("10000")) + val mainAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(mainStatus), + derivationIndexValue = 0, + ) + + // Secondary account holds the initial currency (available, zero balance → TO) + // plus another available currency with balance. + val initialId = mockCurrencyId("ethereum", "0xUSDT") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInSecondary = mockCryptoCurrency(id = initialId) + val secondaryCompanion = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO) + val secondaryStatus = createCurrencyStatus(secondaryCompanion, fiatAmount = BigDecimal("50")) + + val secondaryAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus, secondaryStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(mainAccount, secondaryAccount)) + + setupAvailability(linkedMapOf(mainOnlyCurrency to true)) + setupAvailability(linkedMapOf(initialInSecondary to true, secondaryCompanion to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // FROM must come from secondary account only — never the main account's high-balance currency. + assertThat(from?.status).isSameInstanceAs(secondaryStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN initial in secondary account is only token in that account WHEN invoke THEN FROM is null`() = + runTest { + // Main account has candidates that must NOT be picked as FROM. + val mainOnlyCurrency = mockCryptoCurrency() + val mainStatus = createCurrencyStatus(mainOnlyCurrency, fiatAmount = BigDecimal("500")) + val mainAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(mainStatus), + derivationIndexValue = 0, + ) + + // Secondary account has only the initial currency (available, no balance → TO). + val initialId = mockCurrencyId("ethereum", "0xUSDT") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInSecondary = mockCryptoCurrency(id = initialId) + + val initialStatus = createCurrencyStatus(initialInSecondary, fiatAmount = BigDecimal.ZERO) + val secondaryAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(mainAccount, secondaryAccount)) + + setupAvailability(linkedMapOf(mainOnlyCurrency to true)) + setupAvailability(linkedMapOf(initialInSecondary to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // Secondary account has no other candidates; FROM must be null, not pulled from main. + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + // endregion + + // region currency position FROM + + @Test + fun `GIVEN position FROM and available with balance WHEN invoke THEN returns selected as from`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.FROM, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + @Test + fun `GIVEN position FROM and not available without balance WHEN invoke THEN still returns selected as from`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = null) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.FROM, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(status) + assertThat(to).isNull() + } + + // endregion + + // region currency position TO + + @Test + fun `GIVEN position TO and available with balance WHEN invoke THEN returns selected as to`() = runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = BigDecimal("100")) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.TO, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + @Test + fun `GIVEN position TO and not available without balance WHEN invoke THEN still returns selected as to`() = + runTest { + val sharedId = mockk(relaxed = true) + val initialCurrency = mockCryptoCurrency(id = sharedId) + val accountCurrency = mockCryptoCurrency(id = sharedId) + + val status = createCurrencyStatus(accountCurrency, fiatAmount = null) + val accountStatus = createCryptoPortfolioAccountStatus(listOf(status)) + setupSupplier(listOf(accountStatus)) + setupAvailability(linkedMapOf(accountCurrency to false)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.TO, + isPaymentAccount = false, + ) + + assertThat(from).isNull() + assertThat(to?.status).isSameInstanceAs(status) + } + + // endregion + + // region helpers + + private fun mockCryptoCurrency( + id: CryptoCurrency.ID = mockCurrencyId(), + ): CryptoCurrency = mockk(relaxed = true) { + every { this@mockk.id } returns id + } + + private fun mockCurrencyId( + rawNetworkId: String = "net-${uniqueIndex++}", + contractAddress: String = "contract-${uniqueIndex++}", + ): CryptoCurrency.ID = mockk(relaxed = true) { + every { this@mockk.rawNetworkId } returns rawNetworkId + every { this@mockk.contractAddress } returns contractAddress + } + + private fun createCurrencyStatus( + currency: CryptoCurrency, + fiatAmount: BigDecimal?, + ): CryptoCurrencyStatus { + val value = mockk { + every { this@mockk.fiatAmount } returns fiatAmount + } + return CryptoCurrencyStatus(currency = currency, value = value) + } + + private fun createCryptoPortfolioAccountStatus( + currencies: List, + ): AccountStatus.CryptoPortfolio { + val account = Account.CryptoPortfolio.createMainAccount(userWalletId = userWalletId) + return AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), + sortedBy = TokensSortType.NONE, + currencies = currencies, + ), + priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)), + ) + } + + private fun createCryptoPortfolioAccountStatus( + currencies: List, + derivationIndexValue: Int, + ): AccountStatus.CryptoPortfolio { + val derivationIndex = requireNotNull(DerivationIndex(value = derivationIndexValue).getOrNull()) { + "Invalid derivation index for test: $derivationIndexValue" + } + val accountId = AccountId.forCryptoPortfolio( + userWalletId = userWalletId, + derivationIndex = derivationIndex, + ) + val accountName = if (derivationIndex.isMain) { + AccountName.DefaultMain + } else { + requireNotNull(AccountName.Custom(value = "Account $derivationIndexValue").getOrNull()) { + "Invalid account name for test" + } + } + val account = Account.CryptoPortfolio( + accountId = accountId, + accountName = accountName, + icon = CryptoPortfolioIcon.ofMainAccount(userWalletId), + derivationIndex = derivationIndex, + ) + return AccountStatus.CryptoPortfolio( + account = account, + tokenList = TokenList.Ungrouped( + totalFiatBalance = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), + sortedBy = TokensSortType.NONE, + currencies = currencies, + ), + priceChangeLce = Lce.Content(PriceChange(value = BigDecimal.ZERO, source = StatusSource.ACTUAL)), + ) + } + + private fun setupSupplier(accountStatuses: List) { + val accountStatusList = if (accountStatuses.isEmpty()) { + null + } else { + AccountStatusList( + userWalletId = userWalletId, + accountStatuses = accountStatuses, + totalAccounts = accountStatuses.size, + totalArchivedAccounts = 0, + totalFiatBalance = TotalFiatBalance.Loaded( + amount = BigDecimal.ZERO, + source = StatusSource.ACTUAL, + ), + sortType = TokensSortType.NONE, + groupType = TokensGroupType.NONE, + ) + } + coEvery { + singleAccountStatusListSupplier.getSyncOrNull(any(), any()) + } returns accountStatusList + } + + private fun setupAvailability(currenciesAvailability: LinkedHashMap) { + val result = currenciesAvailability.map { (currency, available) -> + val reason = if (available) { + ScenarioUnavailabilityReason.None + } else { + ScenarioUnavailabilityReason.Unreachable + } + currency to reason + }.toMap() + coEvery { rampStateManager.availableForSwap(userWalletId, currenciesAvailability.keys.toList()) } returns result + } + + // endregion +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index dd917953d6..1e357c518b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -207,9 +207,9 @@ internal class TangemPayCardPageModel @Inject constructor( bottomSheetNavigation.dismiss() router.push( AppRoute.Swap( - currencyFrom = data.currency, + cryptoCurrency = data.currency, userWalletId = data.walletId, - isInitialReverseOrder = true, + currencyPosition = AppRoute.Swap.CurrencyPosition.TO, screenSource = AnalyticsParam.ScreensSources.TangemPay.value, tangemPayInput = AppRoute.Swap.TangemPayInput( cryptoAmount = data.cryptoBalance, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 87c07ef8ba..9de0999957 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -55,6 +55,7 @@ import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -62,7 +63,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -314,10 +314,10 @@ internal class TangemPayDetailsModel @Inject constructor( ) { router.push( AppRoute.Swap( - currencyFrom = currency, + cryptoCurrency = currency, userWalletId = params.userWalletId, - isInitialReverseOrder = false, screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + currencyPosition = AppRoute.Swap.CurrencyPosition.FROM, tangemPayInput = AppRoute.Swap.TangemPayInput( cryptoAmount = currentBalance.availableForWithdrawal, fiatAmount = currentBalance.availableForWithdrawal, @@ -425,10 +425,10 @@ internal class TangemPayDetailsModel @Inject constructor( bottomSheetNavigation.dismiss() router.push( AppRoute.Swap( - currencyFrom = data.currency, + cryptoCurrency = data.currency, userWalletId = data.walletId, - isInitialReverseOrder = true, screenSource = AnalyticsParam.ScreensSources.TangemPay.value, + currencyPosition = AppRoute.Swap.CurrencyPosition.TO, tangemPayInput = AppRoute.Swap.TangemPayInput( cryptoAmount = data.cryptoBalance, fiatAmount = data.fiatBalance, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index bf086562db..89fba14379 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -787,7 +787,7 @@ internal class TokenDetailsModel @Inject constructor( } else { appRouter.push( AppRoute.Swap( - currencyFrom = cryptoCurrency, + cryptoCurrency = cryptoCurrency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.Token.value, ), @@ -1307,7 +1307,7 @@ internal class TokenDetailsModel @Inject constructor( TokenAction.Send -> sendCurrency() TokenAction.Swap -> appRouter.push( AppRoute.Swap( - currencyFrom = cryptoCurrency, + cryptoCurrency = cryptoCurrency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.Token.value, ), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt index d0737b6df2..c65cac824f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/express/ExchangeUM.kt @@ -3,6 +3,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.express import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateInfoUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification @@ -15,6 +16,7 @@ internal data class ExchangeUM( val statuses: ImmutableList, val notification: ExchangeStatusNotification?, val showProviderLink: Boolean, + val fromUserWalletId: UserWalletId, val fromCryptoCurrency: CryptoCurrency, val toCryptoCurrency: CryptoCurrency, val hasLongTime: Boolean, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index 78540b1ba8..c55c898406 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -19,6 +19,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.quote.mapData +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed @@ -98,6 +99,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( val showProviderLink = getShowProviderLink(notification, transaction.status) result.add( ExchangeUM( + fromUserWalletId = UserWalletId(swapTransaction.fromUserWalletId), provider = transaction.provider, statuses = getStatuses(statusModel?.status), notification = notification, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt index 3bc30b0d7c..e2465a65bd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/ExchangeStatusFactory.kt @@ -12,8 +12,10 @@ import com.tangem.domain.models.account.AccountId 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.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* @@ -21,6 +23,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.model.ExpressTr import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter import com.tangem.utils.Provider +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,7 +32,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.map -import com.tangem.utils.logging.TangemLogger @Suppress("LongParameterList") internal class ExchangeStatusFactory @AssistedInject constructor( @@ -40,6 +42,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, + private val getUserWalletUseCase: GetUserWalletUseCase, @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val appCurrencyProvider: Provider, @Assisted private val currentStateProvider: Provider, @@ -59,6 +62,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( operator fun invoke(): Flow> { return swapTransactionRepository.getTransactions( userWallet = userWallet, + cryptoCurrencyId = cryptoCurrency.id, ).conflate() .map { savedTransactions -> @@ -93,7 +97,7 @@ internal class ExchangeStatusFactory @AssistedInject constructor( return if (swapTx.activeStatus?.isTerminal == true) { swapTx } else { - val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider) + val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId) if (statusModel != null) { swapTransactionsStateConverter.updateTxStatus( @@ -106,15 +110,24 @@ internal class ExchangeStatusFactory @AssistedInject constructor( } } - private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? { - return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId) + private suspend fun getExchangeStatus( + txId: String, + provider: SwapProvider, + fromUserWalletId: UserWalletId, + ): ExchangeStatusModel? { + val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrNull() + return swapRepository.getExchangeStatus( + userWallet = fromUserWallet, + userWalletId = fromUserWalletId, + txId = txId, + ) .fold( ifLeft = { null }, ifRight = { statusModel -> sendStatusUpdateAnalytics(statusModel, provider) val accountId = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWallet.walletId, + userWalletId = fromUserWalletId, currency = cryptoCurrency, ) .map { it.account.accountId } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt index a8af24a292..c7b54127e2 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/express/TokenDetailsExchangeStatusFactory.kt @@ -1,5 +1,6 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express +import arrow.core.getOrElse import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.datasource.local.swap.ExpressAnalyticsStatus @@ -11,8 +12,10 @@ import com.tangem.domain.models.account.AccountId 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.models.wallet.UserWalletId import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.tokens.model.analytics.TokenExchangeAnalyticsEvent +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.feature.swap.domain.SwapTransactionRepository import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.domain.* @@ -21,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsSwapTransactionsStateConverter import com.tangem.utils.Provider +import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,7 +33,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.map -import com.tangem.utils.logging.TangemLogger import kotlin.coroutines.cancellation.CancellationException @Suppress("LongParameterList") @@ -41,6 +44,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val swapTransactionStatusStore: SwapTransactionStatusStore, private val analyticsEventsHandler: AnalyticsEventHandler, + private val getUserWalletUseCase: GetUserWalletUseCase, @Assisted private val clickIntents: ExpressTransactionsClickIntents, @Assisted private val appCurrencyProvider: Provider, @Assisted private val currentStateProvider: Provider, @@ -94,7 +98,7 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor( return if (swapTx.activeStatus?.isTerminal == true) { swapTx } else { - val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider) + val statusModel = getExchangeStatus(swapTx.info.txId, swapTx.provider, swapTx.fromUserWalletId) if (statusModel != null) { swapTransactionsStateConverter.updateTxStatus( @@ -107,43 +111,54 @@ internal class TokenDetailsExchangeStatusFactory @AssistedInject constructor( } } - private suspend fun getExchangeStatus(txId: String, provider: SwapProvider): ExchangeStatusModel? { - return swapRepository.getExchangeStatus(userWallet = userWallet, txId = txId) - .fold( - ifLeft = { null }, - ifRight = { statusModel -> - sendStatusUpdateAnalytics(statusModel, provider) + private suspend fun getExchangeStatus( + txId: String, + provider: SwapProvider, + fromUserWalletId: UserWalletId, + ): ExchangeStatusModel? { + val fromUserWallet = getUserWalletUseCase(fromUserWalletId).getOrElse { error -> + TangemLogger.e("Couldn't find userWallet: $error") + return null + } + return swapRepository.getExchangeStatus( + userWallet = fromUserWallet, + userWalletId = fromUserWalletId, + txId = txId, + ).fold( + ifLeft = { null }, + ifRight = { statusModel -> + sendStatusUpdateAnalytics(statusModel, provider) - val accountId = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = userWallet.walletId, - currency = cryptoCurrency, - ) - .map { it.account.accountId } - .getOrNull() + val accountId = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = fromUserWalletId, + currency = cryptoCurrency, + ) + .map { it.account.accountId } + .getOrNull() - val refundTokenCurrency = if (accountId != null) { - addRefundCurrencyIfNeeded( - accountId = accountId, - status = statusModel, - type = provider.type, - ) - } else { - TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}") - null - } - - swapTransactionRepository.storeTransactionState( - txId = txId, + val refundTokenCurrency = if (accountId != null) { + addRefundCurrencyIfNeeded( + accountId = accountId, status = statusModel, - accountWithCurrency = if (refundTokenCurrency != null) { - Pair(accountId, refundTokenCurrency) - } else { - null - }, + type = provider.type, ) - statusModel.copy(refundCurrency = refundTokenCurrency) - }, - ) + } else { + TangemLogger.e("Account ID is null, cannot add refund currency ${cryptoCurrency.id}") + null + } + + swapTransactionRepository.storeTransactionState( + txId = txId, + status = statusModel, + accountWithCurrency = if (refundTokenCurrency != null) { + Pair(accountId, refundTokenCurrency) + } else { + null + }, + ) + statusModel.copy(refundCurrency = refundTokenCurrency) + }, + ) } private suspend fun sendStatusUpdateAnalytics(statusModel: ExchangeStatusModel, provider: SwapProvider) { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt index 8e92b4c203..dfcff712d9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency.ID import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId 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.SwapProvider @@ -23,6 +24,7 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider checkSwapCryptoAvailability( - tokenCount = tokenListState.items.count { it is TokensListItemUM.Token }, - ) - is WalletTokensListState.ContentState.PortfolioContent -> checkSwapCryptoAvailability( - tokenCount = tokenListState.items.sumOf { it.tokens.count { it is TokensListItemUM.Token } }, - ) + when (selectedWallet.tokensListState) { + is WalletTokensListState.ContentState.Content, + is WalletTokensListState.ContentState.PortfolioContent, + -> Unit WalletTokensListState.ContentState.Loading, WalletTokensListState.ContentState.Locked, WalletTokensListState.Empty, @@ -470,7 +466,10 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( modelScope.launch { val swapRoute = getSwapRoute( - AppRoute.SwapCrypto(userWalletId = userWalletId), + AppRoute.Swap( + userWalletId = userWalletId, + screenSource = AnalyticsParam.ScreensSources.Main.value, + ), ) onMultiWalletActionClick( statusFlow = rampStateManager.getExpressInitializationStatus(userWalletId), @@ -660,7 +659,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( private fun navigateToSwap(cryptoCurrencyStatus: CryptoCurrencyStatus, userWalletId: UserWalletId) { appRouter.push( AppRoute.Swap( - currencyFrom = cryptoCurrencyStatus.currency, + cryptoCurrency = cryptoCurrencyStatus.currency, userWalletId = userWalletId, screenSource = AnalyticsParam.ScreensSources.LongTap.value, ), @@ -675,11 +674,4 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor( } } } - - private fun checkSwapCryptoAvailability(tokenCount: Int) { - if (tokenCount < 2) { - analyticsEventHandler.send(event = MainScreenAnalyticsEvent.ButtonSwap(AnalyticsParam.Status.Error)) - uiMessageSender.send(WalletAlertUM.insufficientTokensCountForSwapping()) - } - } } \ No newline at end of file From 01a658effa01bc108ec736972646686576323e27 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Apr 2026 15:26:06 +0100 Subject: [PATCH 102/206] Updated on 2026-08-14 --- .../DefaultAddressSyncComponent.kt | 2 +- .../addresssync/model/AddressSyncContract.kt | 10 ++- .../v2/addresssync/model/AddressSyncModel.kt | 27 ++++++-- .../addresssync/ui/AddressSyncButtonScreen.kt | 5 +- .../addresssync/model/AddressSyncModelTest.kt | 61 ++++++++++++++++++- 5 files changed, 94 insertions(+), 11 deletions(-) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index ab4f310b25..2cca709ec2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -82,7 +82,7 @@ internal class DefaultAddressSyncComponent( model.onIntent(AddressSyncIntent.Sync) }, ) - AddressSyncState.NoTokens -> LaunchedEffect(Unit) { + AddressSyncState.Exit -> LaunchedEffect(Unit) { router.replaceAll(AppRoute.Wallet) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt index f283b138e2..734633db22 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.addresssync.model +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep internal sealed interface AddressSyncIntent { @@ -9,6 +10,11 @@ internal sealed interface AddressSyncIntent { internal sealed class AddressSyncState { data object Loading : AddressSyncState() - data class Success(val currenciesCount: Int) : AddressSyncState() - data object NoTokens : AddressSyncState() + data class Success( + val currencies: List, + val isButtonLoading: Boolean = false, + ) : AddressSyncState() { + val currenciesCount: Int = currencies.size + } + data object Exit : AddressSyncState() } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt index d140c874f7..56a5c838ae 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -12,11 +12,13 @@ import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase import com.tangem.domain.tokens.MultiWalletAccountListFetcher +import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -31,6 +33,7 @@ internal class AddressSyncModel @Inject constructor( private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase, private val multiWalletAccountListFetcher: MultiWalletAccountListFetcher, private val multiAccountListSupplier: MultiAccountListSupplier, + private val derivePublicKeysUseCase: DerivePublicKeysUseCase, paramsContainer: ParamsContainer, ) : Model() { @@ -107,7 +110,7 @@ internal class AddressSyncModel @Inject constructor( params = MultiWalletAccountListFetcher.Params(userWalletId = walletId), ).fold( ifLeft = { - state.value = AddressSyncState.NoTokens + state.value = AddressSyncState.Exit }, ifRight = { handleAddressSyncStep() @@ -125,9 +128,9 @@ internal class AddressSyncModel @Inject constructor( } .onEach { currencies -> val updatedState = if (currencies.isEmpty()) { - AddressSyncState.NoTokens + AddressSyncState.Exit } else { - AddressSyncState.Success(currenciesCount = currencies.size) + AddressSyncState.Success(currencies = currencies) } state.value = updatedState } @@ -135,7 +138,23 @@ internal class AddressSyncModel @Inject constructor( } private fun startSyncing() { - TODO("Will be implemented during [REDACTED_TASK_KEY]") + modelScope.launch { + val successWithLoading = (state.value as AddressSyncState.Success).copy(isButtonLoading = true) + state.value = successWithLoading + val cryptoCurrencies = successWithLoading.currencies + derivePublicKeysUseCase( + userWalletId = walletId, + currencies = cryptoCurrencies, + ).fold( + ifLeft = { throwable -> + state.value = successWithLoading.copy( + isButtonLoading = false, + ) + TangemLogger.e("Failed to derive public keys", throwable) + }, + ifRight = { state.value = AddressSyncState.Exit }, + ) + } } private companion object { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt index 94e2d60121..4b1bf6350c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/AddressSyncButtonScreen.kt @@ -67,6 +67,7 @@ internal fun AddressSyncButtonScreen( top = TangemTheme.dimens.spacing154, bottom = TangemTheme.dimens.spacing16, ), + showProgress = state.isButtonLoading, ) } } @@ -98,7 +99,9 @@ private fun ColumnScope.AddressSyncDescription(currenciesCount: Int) { private fun AddressSyncButtonScreenPreview() { TangemThemePreview { AddressSyncButtonScreen( - state = AddressSyncState.Success(currenciesCount = 2), + state = AddressSyncState.Success( + currencies = emptyList(), + ), onSyncClick = {}, ) } diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt index 6cd4a728ba..c188cfeb15 100644 --- a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -12,6 +12,7 @@ import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase import com.tangem.domain.tokens.MultiWalletAccountListFetcher +import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.features.onboarding.v2.TitleProvider import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent @@ -39,6 +40,7 @@ internal class AddressSyncModelTest { private val shouldAskPermissionUseCase: ShouldAskPermissionUseCase = mockk() private val multiWalletAccountListFetcher: MultiWalletAccountListFetcher = mockk() private val multiAccountListSupplier: MultiAccountListSupplier = mockk() + private val derivePublicKeysUseCase: DerivePublicKeysUseCase = mockk() private val paramsContainer: ParamsContainer = mockk() private val testInnerNavigation = MutableStateFlow( value = MultiWalletInnerNavigationState( @@ -62,6 +64,7 @@ internal class AddressSyncModelTest { coEvery { shouldShowAskBiometryUseCase() } returns false coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false coEvery { multiWalletAccountListFetcher.invoke(any()) } returns Either.Right(Unit) + coEvery { derivePublicKeysUseCase(any(), any()) } returns Either.Right(Unit) every { multiAccountListSupplier() } returns flowOf( listOf(AccountList.empty(userWalletId = walletId)), ) @@ -172,7 +175,7 @@ internal class AddressSyncModelTest { params = MultiWalletAccountListFetcher.Params(userWalletId = walletId) ) } - Assertions.assertEquals(AddressSyncState.NoTokens, model.state.value) + Assertions.assertEquals(AddressSyncState.Exit, model.state.value) } @Test @@ -196,7 +199,7 @@ internal class AddressSyncModelTest { ) } Assertions.assertEquals( - AddressSyncState.Success(currenciesCount = currencies.size), + AddressSyncState.Success(currencies), model.state.value, ) } @@ -226,7 +229,58 @@ internal class AddressSyncModelTest { ) } Assertions.assertEquals( - AddressSyncState.NoTokens, + AddressSyncState.Exit, + model.state.value, + ) + } + + @Test + fun `GIVEN success state WHEN Sync THEN state becomes Exit`() = runTest { + val currencies = listOf(mockk(), mockk(), mockk()) + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = currencies, + ), + ), + ) + coEvery { derivePublicKeysUseCase(walletId, currencies) } returns Either.Right(Unit) + + val model = createModel(this) + advanceUntilIdle() + + model.onIntent(AddressSyncIntent.Sync) + advanceUntilIdle() + + coVerify { derivePublicKeysUseCase(walletId, currencies) } + Assertions.assertEquals(AddressSyncState.Exit, model.state.value) + } + + @Test + fun `GIVEN success state WHEN Sync AND derive fails THEN button loading is reset`() = runTest { + val currencies = listOf(mockk(), mockk(), mockk()) + every { multiAccountListSupplier() } returns flowOf( + listOf( + AccountList.empty( + userWalletId = walletId, + cryptoCurrencies = currencies, + ), + ), + ) + coEvery { derivePublicKeysUseCase(walletId, currencies) } returns Either.Left( + value = IllegalStateException("Test"), + ) + + val model = createModel(this) + advanceUntilIdle() + + model.onIntent(AddressSyncIntent.Sync) + advanceUntilIdle() + + coVerify { derivePublicKeysUseCase(walletId, currencies) } + Assertions.assertEquals( + AddressSyncState.Success(currencies = currencies, isButtonLoading = false), model.state.value, ) } @@ -254,6 +308,7 @@ internal class AddressSyncModelTest { shouldAskPermissionUseCase = shouldAskPermissionUseCase, multiWalletAccountListFetcher = multiWalletAccountListFetcher, multiAccountListSupplier = multiAccountListSupplier, + derivePublicKeysUseCase = derivePublicKeysUseCase, paramsContainer = paramsContainer, ) } From 72989eab5a1a826c30932986aac826f01c5e901f Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Apr 2026 15:36:52 +0400 Subject: [PATCH 103/206] Updated on 2026-08-14 --- .../news/repository/DefaultNewsRepository.kt | 18 ++++++++++-------- .../tangem/domain/news/model/NewsListConfig.kt | 2 -- .../domain/news/repository/NewsRepository.kt | 5 ++--- .../news/usecase/FetchTrendingNewsUseCase.kt | 6 +----- .../news/usecase/ObserveNewsDetailsUseCase.kt | 4 ++-- .../market/details/MarketsTokenDetailsModel.kt | 1 - .../model/news/details/NewsDetailsModel.kt | 10 ++-------- .../details/NewsDetailsPaginationManager.kt | 7 +------ .../feed/model/news/list/NewsListModel.kt | 6 +----- .../statemanager/NewsListBatchFlowManager.kt | 3 --- 10 files changed, 19 insertions(+), 43 deletions(-) diff --git a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt index 293cc63837..7ec5280c72 100644 --- a/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt +++ b/data/news/src/main/java/com/tangem/data/news/repository/DefaultNewsRepository.kt @@ -26,6 +26,7 @@ import com.tangem.domain.news.repository.NewsRepository import com.tangem.pagination.* import com.tangem.pagination.exception.EndOfPaginationException import com.tangem.pagination.fetcher.BatchFetcher +import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger @@ -47,6 +48,9 @@ internal class DefaultNewsRepository( private val newsErrorResolver: NewsErrorResolver, ) : NewsRepository { + private val language: String + get() = SupportedLanguages.getCurrentSupportedLanguageCode() + override fun getNewsListBatchFlow(context: NewsListBatchingContext, batchSize: Int): NewsListBatchFlow { val newsBatchFlow = BatchListSource( fetchDispatcher = dispatchers.io, @@ -63,7 +67,7 @@ internal class DefaultNewsRepository( val items = newsApi.getNews( page = FIRST_PAGE, limit = limit, - language = config.language, + language = language, snapshot = config.snapshot, tokenIds = config.tokenIds.takeIf { it.isNotEmpty() }, categoryIds = config.categoryIds.takeIf { it.isNotEmpty() }, @@ -105,12 +109,10 @@ internal class DefaultNewsRepository( } } - override suspend fun fetchDetailedArticles( - newsIds: Collection, - language: String?, - ): Either, Unit> = fetchDetailedArticlesInternal(newsIds = newsIds, language = language) + override suspend fun fetchDetailedArticles(newsIds: Collection): Either, Unit> = + fetchDetailedArticlesInternal(newsIds = newsIds, language = language) - override suspend fun fetchTrendingNews(limit: Int, language: String?) { + override suspend fun fetchTrendingNews(limit: Int) { fetchAndStoreTrendingNews(limit = limit, language = language) } @@ -259,7 +261,7 @@ internal class DefaultNewsRepository( ) } - private class NewsBatchFetcher( + private inner class NewsBatchFetcher( private val newsApi: NewsApi, private val batchSize: Int, private val newsViewedStore: NewsViewedStore, @@ -326,7 +328,7 @@ internal class DefaultNewsRepository( val response = newsApi.getNews( page = page, limit = limit, - language = params.language, + language = language, snapshot = snapshotOverride?.takeIf { it.isNotEmpty() }, tokenIds = params.tokenIds.takeIf { it.isNotEmpty() }, categoryIds = params.categoryIds.takeIf { it.isNotEmpty() }, diff --git a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt index 67423a773a..a57cb2cd36 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/model/NewsListConfig.kt @@ -7,14 +7,12 @@ import kotlinx.serialization.Serializable * [REDACTED_AUTHOR] * - * @param language device locale (ex: en, ru). * @param snapshot id snapshot (`meta.asOf`) to stabilize responses. * @param tokenIds filter by tokens. * @param categoryIds filter by category. */ @Serializable data class NewsListConfig( - val language: String, val snapshot: String?, val tokenIds: List = emptyList(), val categoryIds: List = emptyList(), diff --git a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt index d122f330f6..b1731a7431 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/repository/NewsRepository.kt @@ -38,15 +38,14 @@ interface NewsRepository { /** * Fetches and caches detailed articles for provided ids in parallel. */ - suspend fun fetchDetailedArticles(newsIds: Collection, language: String?): Either, Unit> + suspend fun fetchDetailedArticles(newsIds: Collection): Either, Unit> /** * Fetch list of trending news by limit and with correct locale and store it in runtime data store. * * @param limit - * @param language current device locale */ - suspend fun fetchTrendingNews(limit: Int, language: String?) + suspend fun fetchTrendingNews(limit: Int) /** * Observes trending news with runtime viewed flag support. diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt index fc69c6ba03..da5f344fbd 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/FetchTrendingNewsUseCase.kt @@ -2,7 +2,6 @@ package com.tangem.domain.news.usecase import arrow.core.Either import com.tangem.domain.news.repository.NewsRepository -import java.util.Locale /** * Fetches trending news to store it in runtime data store. @@ -11,10 +10,7 @@ import java.util.Locale class FetchTrendingNewsUseCase(private val newsRepository: NewsRepository) { suspend operator fun invoke(): Either = Either.catch { - newsRepository.fetchTrendingNews( - limit = LIMIT_FOR_TRENDING_NEWS, - language = Locale.getDefault().language, - ) + newsRepository.fetchTrendingNews(limit = LIMIT_FOR_TRENDING_NEWS) } companion object { diff --git a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt index 06d9678f90..e3a8d39bf4 100644 --- a/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt +++ b/domain/news/src/main/java/com/tangem/domain/news/usecase/ObserveNewsDetailsUseCase.kt @@ -24,6 +24,6 @@ class ObserveNewsDetailsUseCase( /** * Prefetches the given article ids (can be called with current + next ids for pager preloading). */ - suspend fun prefetch(newsIds: Collection, language: String?): Either, Unit> = - repository.fetchDetailedArticles(newsIds, language) + suspend fun prefetch(newsIds: Collection): Either, Unit> = + repository.fetchDetailedArticles(newsIds) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index e412504e4d..024e7ff952 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -336,7 +336,6 @@ internal class MarketsTokenDetailsModel @Inject constructor( getNewsUseCase.getNews( limit = RELATED_NEWS_LIMIT, newsListConfig = NewsListConfig( - language = Locale.getDefault().language, snapshot = null, tokenIds = listOf(params.token.id.value), ), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt index 8c816de944..15677f60a6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsModel.kt @@ -32,12 +32,11 @@ import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger -import java.util.Locale import javax.inject.Inject @Stable @@ -60,14 +59,12 @@ internal class NewsDetailsModel @Inject constructor( ) : Model() { private val params = paramsContainer.require() - private val currentLanguage = Locale.getDefault().language private val newsDetailsConverter = NewsDetailsConverter(onRelatedArticleClick = ::onRelatedArticleClick) private val paginationManager: NewsDetailsPaginationManager? = params.paginationConfig?.let { config -> NewsDetailsPaginationManager( getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, - currentLanguage = Provider { config.language }, currentCategoryIds = Provider { config.categoryIds }, modelScope = modelScope, dispatchers = dispatchers, @@ -212,10 +209,7 @@ internal class NewsDetailsModel @Inject constructor( } private suspend fun initialPrefetch() { - observeNewsDetailsUseCase.prefetch( - newsIds = params.preselectedArticlesId, - language = currentLanguage, - ).onLeft { errors -> + observeNewsDetailsUseCase.prefetch(newsIds = params.preselectedArticlesId).onLeft { errors -> errors.onEach { (newsId, error) -> when { // an article is opened from deeplink and is not found diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt index 7ec0b5f531..359d174d80 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/NewsDetailsPaginationManager.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.launch @Suppress("LongParameterList") internal class NewsDetailsPaginationManager( private val observeNewsDetailsUseCase: ObserveNewsDetailsUseCase, - private val currentLanguage: Provider, getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, dispatchers: CoroutineDispatcherProvider, currentCategoryIds: Provider>, @@ -23,7 +22,6 @@ internal class NewsDetailsPaginationManager( isRedesignEnabled: Boolean, ) : NewsListBatchFlowManager( getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, - currentLanguage = currentLanguage, currentCategoryIds = currentCategoryIds, modelScope = modelScope, dispatchers = dispatchers, @@ -53,10 +51,7 @@ internal class NewsDetailsPaginationManager( .distinctUntilChanged() .collect { newIds -> if (newIds.isNotEmpty()) { - observeNewsDetailsUseCase.prefetch( - newsIds = newIds, - language = currentLanguage(), - ) + observeNewsDetailsUseCase.prefetch(newsIds = newIds) _cachedPrefetchedIds.update { it + newIds } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt index 9542f5be8a..409a942b93 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -11,13 +11,12 @@ import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase import com.tangem.features.feed.components.news.list.DefaultNewsListComponent import com.tangem.features.feed.model.news.list.analytics.NewsListAnalyticsEvent -import com.tangem.features.feed.model.news.list.statemanager.NewsListStateManager import com.tangem.features.feed.model.news.list.loader.NewsCategoriesLoader import com.tangem.features.feed.model.news.list.statemanager.NewsListBatchFlowManager +import com.tangem.features.feed.model.news.list.statemanager.NewsListStateManager import com.tangem.features.feed.ui.news.list.state.NewsListState import com.tangem.features.feed.ui.news.list.state.NewsListUM import com.tangem.utils.Provider -import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -41,7 +40,6 @@ internal class NewsListModel @Inject constructor( private val params = paramsContainer.require() private val selectedCategoryId = MutableStateFlow(null) - private val currentLanguage = SupportedLanguages.getCurrentSupportedLanguageCode() private val categoriesLoader by lazy { NewsCategoriesLoader( @@ -54,7 +52,6 @@ internal class NewsListModel @Inject constructor( private val batchFlowManager by lazy { NewsListBatchFlowManager( getNewsListBatchFlowUseCase = getNewsListBatchFlowUseCase, - currentLanguage = Provider { currentLanguage }, currentCategoryIds = Provider { selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty() }, @@ -158,7 +155,6 @@ internal class NewsListModel @Inject constructor( private fun createNewsListConfig(): NewsListConfig { return NewsListConfig( - language = currentLanguage, snapshot = null, tokenIds = emptyList(), categoryIds = selectedCategoryId.value?.takeIf { it > 0 }?.let { listOf(it) }.orEmpty(), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt index a6e5d5a2fd..03565cab2b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/statemanager/NewsListBatchFlowManager.kt @@ -20,11 +20,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -@Suppress("LongParameterList") internal open class NewsListBatchFlowManager( private val isRedesignEnabled: Boolean, getNewsListBatchFlowUseCase: GetNewsListBatchFlowUseCase, - private val currentLanguage: Provider, private val currentCategoryIds: Provider>, protected val modelScope: CoroutineScope, protected val dispatchers: CoroutineDispatcherProvider, @@ -146,7 +144,6 @@ internal open class NewsListBatchFlowManager( private fun createNewsListConfig(): NewsListConfig { return NewsListConfig( - language = currentLanguage(), snapshot = null, tokenIds = emptyList(), categoryIds = currentCategoryIds(), From 361bd8168bc9f7f12fd67acb2056014778fac52f Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Apr 2026 18:33:00 +0300 Subject: [PATCH 104/206] Updated on 2026-08-14 --- .../DynamicAddressesInitializer.kt | 3 + .../derivations/MissedDerivationsFinder.kt | 6 +- .../MissedDerivationsFinderTest.kt | 67 +++++++++++++++++++ .../dynamicaddresses/GetDerivedXpubUseCase.kt | 1 - 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt index 6eae2bf3b5..ec96dcdcea 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DynamicAddressesInitializer.kt @@ -1,6 +1,7 @@ package com.tangem.data.dynamicaddresses import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository @@ -27,6 +28,8 @@ class DynamicAddressesInitializer @Inject constructor( val result = mutableMapOf() for (network in networks) { + if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.rawId)) continue + val status = dynamicAddressesRepository.getStatus(userWalletId, network).firstOrNull() if (status != DynamicAddressesStatus.ENABLED_REQUIRES_SETUP) continue diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt index 9c4c0af642..1b0b9f93b2 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/MissedDerivationsFinder.kt @@ -6,6 +6,7 @@ import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.extensions.toMapKey import com.tangem.crypto.hdWallet.DerivationPath +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.scan.KeyWalletPublicKey @@ -140,11 +141,12 @@ class MissedDerivationsFinder private constructor( * - Account-level (e.g. m/84'/0'/0') — the XPUB itself * - Parent (e.g. m/84'/0') — needed for parent fingerprint in XPUB serialization * - * Only applicable for BIP44-style XPUB blockchains (BTC, BCH, LTC, DOGE, DASH, RVN). + * Only applicable for blockchains listed in [DynamicAddressesSupportedBlockchains] + * (BTC/LTC via BIP-84 SegWit, BCH/DOGE/DASH/RVN via BIP-44, plus their testnets). */ private fun Blockchain.getXpubDerivationPaths(derivationPath: DerivationPath): List { if (!isDynamicAddressesEnabled) return emptyList() - if (!isBip44DerivationStyleXPUB()) return emptyList() + if (!DynamicAddressesSupportedBlockchains.isSupported(this)) return emptyList() val nodes = derivationPath.nodes if (nodes.size < XPUB_MIN_NODES) return emptyList() diff --git a/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt b/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt index b0285167fc..dea2d7df49 100644 --- a/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt +++ b/data/wallets/src/test/java/com/tangem/data/wallets/derivations/MissedDerivationsFinderTest.kt @@ -132,6 +132,73 @@ internal class MissedDerivationsFinderTest { Truth.assertThat(actual).isEmpty() } + @Test + fun `XPUB derivations added for supported blockchain when dynamic addresses enabled`() { + val userWallet = MockUserWalletFactory.create(createWallet2ScanResponse()) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = true) + + val currencies = listOf(MockCryptoCurrencyFactory(userWallet).createCoin(Blockchain.Bitcoin)) + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()), + listOf( + DerivationPath("m/84'/0'/0'/0/0"), // Bitcoin BIP-84 default + DerivationPath("m/84'/0'/0'"), // XPUB account-level path + DerivationPath("m/84'/0'"), // Parent (for XPUB fingerprint) + DerivationPath("m/44'/60'/0'/0/0"), // Ethereum added by enrichBlockchains + ), + ) + } + + @Test + fun `XPUB derivations NOT added for supported blockchain when dynamic addresses disabled`() { + val userWallet = MockUserWalletFactory.create(createWallet2ScanResponse()) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = false) + + val currencies = listOf(MockCryptoCurrencyFactory(userWallet).createCoin(Blockchain.Bitcoin)) + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()), + listOf( + DerivationPath("m/84'/0'/0'/0/0"), + DerivationPath("m/44'/60'/0'/0/0"), + ), + ) + } + + @Test + fun `XPUB derivations NOT added for unsupported blockchain when dynamic addresses enabled`() { + val userWallet = MockUserWalletFactory.create(createWallet2ScanResponse()) + val finder = MissedDerivationsFinder(userWallet = userWallet, isDynamicAddressesEnabled = true) + + val currencies = listOf(MockCryptoCurrencyFactory(userWallet).createCoin(Blockchain.Ethereum)) + val actual = finder.find(currencies) + + Truth.assertThat(actual).containsExactly( + ByteArrayKey(EllipticCurve.Secp256k1.name.toByteArray()), + listOf(DerivationPath("m/44'/60'/0'/0/0")), + ) + } + + /** + * Wallet2 config yields DerivationStyle.V3 (BIP-84 SegWit for BTC/LTC) — the style the + * Dynamic Addresses feature actually targets. [MockScanResponseFactory] hardcodes + * `isHDWalletAllowed = false` for Wallet2, so we patch it to `true` to mirror production + * scans that reach [MissedDerivationsFinder]. + */ + private fun createWallet2ScanResponse() = MockScanResponseFactory.create( + cardConfig = Wallet2CardConfig, + derivedKeys = emptyMap(), + ).let { + it.copy( + card = it.card.copy( + settings = it.card.settings.copy(isHDWalletAllowed = true, isBackupAllowed = true), + ), + ) + } + @Test fun `derivations ONLY for never derived currencies`() { val scanResponse = MockScanResponseFactory.create( diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt index 4c3b3cd2bf..7d5b34d04c 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/GetDerivedXpubUseCase.kt @@ -24,7 +24,6 @@ class GetDerivedXpubUseCase( suspend operator fun invoke(userWalletId: UserWalletId, network: Network): String? { val blockchain = network.toBlockchain() if (!DynamicAddressesSupportedBlockchains.isSupported(blockchain)) return null - if (!blockchain.isBip44DerivationStyleXPUB()) return null val walletManager = walletManagersFacade.getOrCreateWalletManager(userWalletId, network) ?: return null val hdKey = walletManager.wallet.publicKey.derivationType?.hdKey ?: return null From c59d29e8384655e516059101bd916f29a1f0d9c7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 23 Apr 2026 16:39:34 +0100 Subject: [PATCH 105/206] Updated on 2026-08-14 --- .../onboarding/v2/addresssync/model/AddressSyncModelTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt index c188cfeb15..484b8a2932 100644 --- a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -157,7 +157,7 @@ internal class AddressSyncModelTest { } @Test - fun `GIVEN multiAccountListSupplier emits no currencies WHEN model is created THEN state is NoTokens`() = runTest { + fun `GIVEN multiAccountListSupplier emits no currencies WHEN model is created THEN state is Exit`() = runTest { every { multiAccountListSupplier() } returns flowOf( listOf( AccountList.empty( @@ -205,7 +205,7 @@ internal class AddressSyncModelTest { } @Test - fun `WHEN multiWalletAccountListFetcher emits error WHEN model is created THEN get NoToken state`() = runTest { + fun `WHEN multiWalletAccountListFetcher emits error WHEN model is created THEN get Exit state`() = runTest { val currencies = listOf(mockk(), mockk(), mockk()) coEvery { multiWalletAccountListFetcher.invoke(any()) } returns Either.Left( value = IllegalStateException("Test") From 3da4771d666ea6a4999d740a79d01ac25665b7e7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 24 Apr 2026 00:44:59 +0300 Subject: [PATCH 106/206] Updated on 2026-08-14 --- data/dynamic-addresses/build.gradle.kts | 9 + .../DefaultDynamicAddressesRepository.kt | 51 ++++ .../di/DynamicAddressesDataModule.kt | 5 + .../DefaultDynamicAddressesRepositoryTest.kt | 218 ++++++++++++++++++ .../DefaultWalletManagersFacade.kt | 35 ++- .../repository/DynamicAddressesRepository.kt | 8 + .../warnings/DynamicAddressesWarnings.kt | 6 + .../walletmanager/WalletManagersFacade.kt | 9 + .../domain/GetCurrencyWarningsUseCase.kt | 11 +- ...okenDetailsNotificationsAnalyticsSender.kt | 1 + .../model/TokenDetailsClickIntents.kt | 4 + .../tokendetails/model/TokenDetailsModel.kt | 4 + .../components/TokenDetailsNotification.kt | 11 + .../TokenDetailsNotificationConverter.kt | 4 + .../UpdateNotificationsTransformer.kt | 15 ++ .../domain/GetCurrencyWarningsUseCaseTest.kt | 146 ++++++++++++ .../TokenDetailsNotificationConverterTest.kt | 48 ++++ .../UpdateNotificationsTransformerTest.kt | 32 +++ gradle/tangem_dependencies.toml | 2 +- 19 files changed, 605 insertions(+), 14 deletions(-) create mode 100644 data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt create mode 100644 domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt create mode 100644 features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt diff --git a/data/dynamic-addresses/build.gradle.kts b/data/dynamic-addresses/build.gradle.kts index a504298e11..0c1a34ea9b 100644 --- a/data/dynamic-addresses/build.gradle.kts +++ b/data/dynamic-addresses/build.gradle.kts @@ -9,6 +9,10 @@ android { namespace = "com.tangem.data.dynamicaddresses" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { // region Project - Core implementation(projects.core.configToggles) @@ -37,4 +41,9 @@ dependencies { implementation(deps.hilt.android) kapt(deps.hilt.kapt) // endregion + + // region Testing + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.test.core) + // endregion } \ No newline at end of file diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt index 9f36682a75..7342b9439d 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepository.kt @@ -6,6 +6,9 @@ import com.tangem.datasource.api.tangemTech.models.UserTokensResponse import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.network.Network @@ -16,18 +19,30 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.runSuspendCatching import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap +@Suppress("LongParameterList") internal class DefaultDynamicAddressesRepository( private val walletAccountsFetcher: WalletAccountsFetcher, private val walletAccountsSaver: WalletAccountsSaver, private val accountsCRUDRepository: AccountsCRUDRepository, private val walletManagersFacade: WalletManagersFacade, + private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + private val getDerivedXpubUseCase: GetDerivedXpubUseCase, private val dispatchers: CoroutineDispatcherProvider, ) : DynamicAddressesRepository { + private val extraFundsProbeCache = ConcurrentHashMap, Boolean>() + private val extraFundsProbeMutex = Mutex() + override fun getStatus(userWalletId: UserWalletId, network: Network): Flow { return walletAccountsFetcher.get(userWalletId) .map { response -> @@ -49,6 +64,7 @@ internal class DefaultDynamicAddressesRepository( error("Failed to enable xpub mode for $userWalletId / ${network.id}: ${result.error}") } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = true) + invalidateExtraFundsProbe(userWalletId, network) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } .onFailure { throwable -> TangemLogger.e( @@ -66,6 +82,7 @@ internal class DefaultDynamicAddressesRepository( error("Failed to disable xpub mode for $userWalletId / ${network.id}: ${result.error}") } updateTokenDynamicAddressesFlag(userWalletId, network, enabled = false) + invalidateExtraFundsProbe(userWalletId, network) runSuspendCatching { accountsCRUDRepository.syncTokens(userWalletId) } .onFailure { throwable -> TangemLogger.e( @@ -89,6 +106,40 @@ internal class DefaultDynamicAddressesRepository( return walletManagersFacade.hasDynamicAddressesNonBaseBalances(userWalletId, network) } + override fun hasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network): Flow { + if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return flowOf(false) + if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(network.id.rawId.value)) return flowOf(false) + + return getStatus(userWalletId, network) + .distinctUntilChanged() + .map { status -> + if (status != DynamicAddressesStatus.DISABLED) return@map false + probeExtraFundsCached(userWalletId, network) + } + .onStart { emit(false) } + .flowOn(dispatchers.io) + } + + private suspend fun probeExtraFundsCached(userWalletId: UserWalletId, network: Network): Boolean { + val key = userWalletId to network + extraFundsProbeCache[key]?.let { return it } + return extraFundsProbeMutex.withLock { + extraFundsProbeCache[key]?.let { return@withLock it } + + val xpub = getDerivedXpubUseCase(userWalletId, network) ?: return@withLock false + + val hasFunds = walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, xpub) + if (hasFunds) extraFundsProbeCache[key] = true + hasFunds + } + } + + private suspend fun invalidateExtraFundsProbe(userWalletId: UserWalletId, network: Network) { + extraFundsProbeMutex.withLock { + extraFundsProbeCache.remove(userWalletId to network) + } + } + override suspend fun hasConflictingCustomTokens(userWalletId: UserWalletId, network: Network): Boolean { return withContext(dispatchers.io) { val response = walletAccountsFetcher.getSaved(userWalletId) ?: return@withContext false diff --git a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt index 8ace9bf67b..141f6a756e 100644 --- a/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt +++ b/data/dynamic-addresses/src/main/java/com/tangem/data/dynamicaddresses/di/DynamicAddressesDataModule.kt @@ -8,6 +8,7 @@ import com.tangem.domain.account.repository.AccountsCRUDRepository import com.tangem.data.dynamicaddresses.DefaultDynamicAddressesFeatureToggles import com.tangem.data.dynamicaddresses.DefaultDynamicAddressesRepository import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase import com.tangem.domain.dynamicaddresses.repository.ConsolidationRepository import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.walletmanager.WalletManagersFacade @@ -29,6 +30,8 @@ internal object DynamicAddressesDataModule { walletAccountsSaver: WalletAccountsSaver, accountsCRUDRepository: AccountsCRUDRepository, walletManagersFacade: WalletManagersFacade, + dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles, + getDerivedXpubUseCase: GetDerivedXpubUseCase, dispatchers: CoroutineDispatcherProvider, ): DynamicAddressesRepository { return DefaultDynamicAddressesRepository( @@ -36,6 +39,8 @@ internal object DynamicAddressesDataModule { walletAccountsSaver = walletAccountsSaver, accountsCRUDRepository = accountsCRUDRepository, walletManagersFacade = walletManagersFacade, + dynamicAddressesFeatureToggles = dynamicAddressesFeatureToggles, + getDerivedXpubUseCase = getDerivedXpubUseCase, dispatchers = dispatchers, ) } diff --git a/data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt b/data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt new file mode 100644 index 0000000000..4202bd889b --- /dev/null +++ b/data/dynamic-addresses/src/test/kotlin/com/tangem/data/dynamicaddresses/DefaultDynamicAddressesRepositoryTest.kt @@ -0,0 +1,218 @@ +package com.tangem.data.dynamicaddresses + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.extensions.SimpleResult +import com.tangem.data.common.account.WalletAccountsFetcher +import com.tangem.data.common.account.WalletAccountsSaver +import com.tangem.datasource.api.tangemTech.models.account.GetWalletAccountsResponse +import com.tangem.domain.account.repository.AccountsCRUDRepository +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles +import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DefaultDynamicAddressesRepositoryTest { + + private val walletAccountsFetcher: WalletAccountsFetcher = mockk(relaxed = true) + private val walletAccountsSaver: WalletAccountsSaver = mockk(relaxed = true) + private val accountsCRUDRepository: AccountsCRUDRepository = mockk(relaxed = true) + private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + private val featureToggles: DynamicAddressesFeatureToggles = mockk(relaxed = true) + private val getDerivedXpubUseCase: GetDerivedXpubUseCase = mockk(relaxed = true) + + private val userWalletId: UserWalletId = mockk(relaxed = true) + private val network: Network = mockk(relaxed = true) { + every { id.rawId.value } returns SUPPORTED_NETWORK_ID + } + private val otherNetwork: Network = mockk(relaxed = true) { + every { id.rawId.value } returns OTHER_SUPPORTED_NETWORK_ID + } + + private val dispatchers: CoroutineDispatcherProvider = TestDispatchers(Dispatchers.Unconfined) + + private lateinit var repository: DefaultDynamicAddressesRepository + + @BeforeEach + fun setUp() { + clearMocks(walletManagersFacade, featureToggles, getDerivedXpubUseCase, answers = false) + // Empty response → findToken returns null → getStatus emits DISABLED. + val emptyResponse = mockk(relaxed = true) { + every { accounts } returns emptyList() + } + every { walletAccountsFetcher.get(userWalletId) } returns flowOf(emptyResponse) + coEvery { walletManagersFacade.enableXpubMode(any(), any(), any()) } returns SimpleResult.Success + coEvery { walletManagersFacade.disableXpubMode(any(), any()) } returns SimpleResult.Success + + repository = DefaultDynamicAddressesRepository( + walletAccountsFetcher = walletAccountsFetcher, + walletAccountsSaver = walletAccountsSaver, + accountsCRUDRepository = accountsCRUDRepository, + walletManagersFacade = walletManagersFacade, + dynamicAddressesFeatureToggles = featureToggles, + getDerivedXpubUseCase = getDerivedXpubUseCase, + dispatchers = dispatchers, + ) + } + + @Test + fun `GIVEN feature toggle off WHEN collect THEN probe is never called and flow emits false`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns false + + // WHEN + val values = repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN + assertThat(values).doesNotContain(true) + coVerify(exactly = 0) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(any(), any(), any()) + } + } + + @Test + fun `GIVEN toggle on AND xpub is null WHEN collect THEN probe is not called AND cache is empty`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns null + + // WHEN + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + // Second collect should invoke xpub derivation again (nothing is cached for null xpub). + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN + coVerify(exactly = 2) { getDerivedXpubUseCase(userWalletId, network) } + coVerify(exactly = 0) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(any(), any(), any()) + } + } + + @Test + fun `GIVEN probe returns true WHEN collect THEN result is cached AND next collect skips probe`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns true + + // WHEN + val firstValues = repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + val secondValues = repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN + assertThat(firstValues).contains(true) + assertThat(secondValues).contains(true) + coVerify(exactly = 1) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + @Test + fun `GIVEN probe returns false WHEN collect twice THEN probe runs each time`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns false + + // WHEN + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN — negative results are not cached, so the probe must re-run. + coVerify(exactly = 2) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + @Test + fun `GIVEN cached true WHEN enable succeeds THEN cache is invalidated and next probe runs again`() = runTest { + // GIVEN — populate cache with a positive probe + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns true + repository.hasFundsOnAdditionalAddresses(userWalletId, network).first() + + // WHEN — enable() succeeds and invalidates the cache + repository.enable(userWalletId, network, XPUB) + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN — probe was called once before enable, again after invalidation + coVerify(exactly = 2) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + @Test + fun `GIVEN cached true WHEN disable succeeds THEN cache is invalidated and next probe runs again`() = runTest { + // GIVEN + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns true + repository.hasFundsOnAdditionalAddresses(userWalletId, network).first() + + // WHEN + repository.disable(userWalletId, network) + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN + coVerify(exactly = 2) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + @Test + fun `GIVEN cache entry for one network WHEN invalidate other network THEN first entry is preserved`() = runTest { + // GIVEN — cache populated for `network` + every { featureToggles.isDynamicAddressesEnabled } returns true + coEvery { getDerivedXpubUseCase(userWalletId, network) } returns XPUB + coEvery { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } returns true + repository.hasFundsOnAdditionalAddresses(userWalletId, network).first() + + // WHEN — disable invalidates a different network + repository.disable(userWalletId, otherNetwork) + repository.hasFundsOnAdditionalAddresses(userWalletId, network).toList() + + // THEN — `network` cache is untouched; probe was called only once (initial fill) + coVerify(exactly = 1) { + walletManagersFacade.probeHasFundsOnAdditionalAddresses(userWalletId, network, XPUB) + } + } + + private class TestDispatchers(dispatcher: CoroutineDispatcher) : CoroutineDispatcherProvider { + override val main: CoroutineDispatcher = dispatcher + override val mainImmediate: CoroutineDispatcher = dispatcher + override val io: CoroutineDispatcher = dispatcher + override val default: CoroutineDispatcher = dispatcher + override val single: CoroutineDispatcher = dispatcher + } + + private companion object { + const val XPUB = "xpub6-test-value" + const val SUPPORTED_NETWORK_ID = "bitcoin" + const val OTHER_SUPPORTED_NETWORK_ID = "litecoin" + } +} \ No newline at end of file diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index 3401f5d920..3a0617f596 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -442,12 +442,8 @@ internal class DefaultWalletManagersFacade @Inject constructor( ) } - try { - walletManager.enableDynamicAddresses(xpub) - SimpleResult.Success - } catch (e: Exception) { - SimpleResult.Failure(BlockchainSdkError.CustomError(e.message ?: "Failed to enable XPUB mode")) - } + walletManager.enableDynamicAddresses(xpub) + SimpleResult.Success } @Suppress("TooGenericExceptionCaught") @@ -461,12 +457,8 @@ internal class DefaultWalletManagersFacade @Inject constructor( ) } - try { - walletManager.disableDynamicAddresses() - SimpleResult.Success - } catch (e: Exception) { - SimpleResult.Failure(BlockchainSdkError.CustomError(e.message ?: "Failed to disable XPUB mode")) - } + walletManager.disableDynamicAddresses() + SimpleResult.Success } override suspend fun isDynamicAddressesEnabled(userWalletId: UserWalletId, network: Network): Boolean { @@ -507,6 +499,25 @@ internal class DefaultWalletManagersFacade @Inject constructor( } } + override suspend fun probeHasFundsOnAdditionalAddresses( + userWalletId: UserWalletId, + network: Network, + xpub: String, + ): Boolean { + return withContext(dispatchers.io) { + val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network) + val dynamicAddressesManager = walletManager as? DynamicAddressesManager + ?: return@withContext false + when (val result = dynamicAddressesManager.probeHasFundsOnNonBaseAddresses(xpub)) { + is Result.Success -> result.data + is Result.Failure -> { + TangemLogger.w("Xpub probe failed for ${network.id}: ${result.error}") + false + } + } + } + } + private suspend fun getEnabledDynamicAddressesManagerOrNull( userWalletId: UserWalletId, network: Network, diff --git a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt index 8cdf4b0c12..46f50aadd6 100644 --- a/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt +++ b/domain/dynamic-addresses/src/main/kotlin/com/tangem/domain/dynamicaddresses/repository/DynamicAddressesRepository.kt @@ -25,4 +25,12 @@ interface DynamicAddressesRepository { /** Lightweight check: is the DA flag enabled for the native coin of the given network (no xpub availability check) */ fun isDynamicAddressesEnabledForNetwork(userWalletId: UserWalletId, networkId: Network.ID): Flow + + /** + * Emits true when dynamic addresses are DISABLED for this token but a silent xpub probe + * detected non-zero balances on derived addresses beyond the base one. Only positive + * results are cached per session; false results and probe failures are not cached and may + * be re-probed on subsequent collections or when status changes. + */ + fun hasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network): Flow } \ No newline at end of file diff --git a/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt new file mode 100644 index 0000000000..9eb298cf27 --- /dev/null +++ b/domain/tokens/models/src/main/java/com/tangem/domain/tokens/model/warnings/DynamicAddressesWarnings.kt @@ -0,0 +1,6 @@ +package com.tangem.domain.tokens.model.warnings + +sealed class DynamicAddressesWarnings : CryptoCurrencyWarning() { + + data object FundsFound : DynamicAddressesWarnings() +} \ No newline at end of file diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index 854c807db6..b1f17fb70a 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -303,5 +303,14 @@ interface WalletManagersFacade { suspend fun hasDynamicAddressesNonBaseBalances(userWalletId: UserWalletId, network: Network): Boolean + /** + * Silently probes the xpub for balances on non-base derived addresses. + * Does not mutate wallet manager state; can be called when dynamic addresses mode is disabled. + * + * @return true if any non-base derived address has a non-zero balance, false on probe failure, + * when the network doesn't support dynamic addresses, or when no extra funds were found. + */ + suspend fun probeHasFundsOnAdditionalAddresses(userWalletId: UserWalletId, network: Network, xpub: String): Boolean + // endregion Dynamic Addresses } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt index 1c8370811a..15664feeef 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCase.kt @@ -4,7 +4,9 @@ import com.tangem.blockchainsdk.utils.isNeedToCreateAccountWithoutReserve import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCoinStatus import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository import com.tangem.domain.models.StatusSource +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -34,6 +36,7 @@ internal class GetCurrencyWarningsUseCase @Inject constructor( private val currencyChecksRepository: CurrencyChecksRepository, private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, + private val dynamicAddressesRepository: DynamicAddressesRepository, ) { suspend operator fun invoke( @@ -52,7 +55,12 @@ internal class GetCurrencyWarningsUseCase @Inject constructor( flow2 = flowOf(currencyChecksRepository.getRentInfoWarning(userWalletId, currencyStatus)), flow3 = flowOf(currencyChecksRepository.getExistentialDeposit(userWalletId, currency.network)), flow4 = flowOf(currencyChecksRepository.getFeeResourceAmount(userWalletId, currency.network)), - ) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource -> + flow5 = if (currency is CryptoCurrency.Coin) { + dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, currency.network) + } else { + flowOf(false) + }, + ) { coinRelatedWarnings, maybeRentWarning, maybeEdWarning, maybeFeeResource, hasExtraFunds -> setOfNotNull( maybeRentWarning, maybeEdWarning?.let { getExistentialDepositWarning(currency, it) }, @@ -64,6 +72,7 @@ internal class GetCurrencyWarningsUseCase @Inject constructor( getAssetRequirementsWarning(userWalletId = userWalletId, currency = currency), getMigrationFromMaticToPolWarning(currency), getCloreMigrationWarning(currency), + DynamicAddressesWarnings.FundsFound.takeIf { hasExtraFunds }, ) }.flowOn(dispatchers.io) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt index d65921fdb9..ba37caff40 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/analytics/TokenDetailsNotificationsAnalyticsSender.kt @@ -62,6 +62,7 @@ internal class TokenDetailsNotificationsAnalyticsSender( is TokenDetailsNotification.MigrationClore, is TokenDetailsNotification.UsedOutdatedData, -> null + is TokenDetailsNotification.DynamicAddressesFundsFound -> null // TODO: [REDACTED_TASK_KEY] analytics event } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index 81e1b3a3d0..5518cfdedb 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -50,6 +50,8 @@ interface TokenDetailsClickIntents { fun onDynamicAddressesClick() + fun onDynamicAddressesFundsFoundLearnMoreClick() + fun onCopyAddress(): TextReference? fun onAssociateClick() @@ -129,6 +131,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents { override fun onDynamicAddressesClick() { /* no op */ } + override fun onDynamicAddressesFundsFoundLearnMoreClick() { /* no op */ } + override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 89fba14379..b8b37a945e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -707,6 +707,10 @@ internal class TokenDetailsModel @Inject constructor( override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick() + override fun onDynamicAddressesFundsFoundLearnMoreClick() { + // TODO: open "Learn more" URL once the destination is decided + } + private fun onDynamicAddressesStateChanged() { updateTopBarMenu() modelScope.launch(dispatchers.main) { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt index b8f740b9fe..bb05ee4cc5 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/components/TokenDetailsNotification.kt @@ -276,6 +276,17 @@ internal sealed class TokenDetailsNotification(val config: NotificationConfig) { ), ) + data class DynamicAddressesFundsFound( + private val onLearnMoreClick: () -> Unit, + ) : Warning( + title = resourceReference(id = R.string.dynamic_addresses_notification_funds_found_title), + subtitle = resourceReference(id = R.string.dynamic_addresses_notification_funds_found_description), + buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( + text = resourceReference(id = R.string.common_learn_more), + onClick = onLearnMoreClick, + ), + ) + data class YieldSupplyNotTransferedToAave(val tokenName: String, val amount: String) : Warning( title = resourceReference( id = R.string.yield_module_amount_not_transfered_to_aave_title, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt index 802ec6e0bb..669c4a05df 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverter.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.format.bigdecimal.shorted import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings import com.tangem.domain.tokens.model.warnings.HederaWarnings import com.tangem.domain.tokens.model.warnings.KaspaWarnings import com.tangem.domain.wallets.usecase.GetUserWalletUseCase @@ -159,6 +160,9 @@ internal class TokenDetailsNotificationConverter( onMigrationClick = clickIntents::onCloreMigrationClick, ) is CryptoCurrencyWarning.UsedOutdatedDataWarning -> UsedOutdatedData + is DynamicAddressesWarnings.FundsFound -> DynamicAddressesFundsFound( + onLearnMoreClick = clickIntents::onDynamicAddressesFundsFoundLearnMoreClick, + ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt index a8ecb28437..40232ef9c8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformer.kt @@ -12,6 +12,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings import com.tangem.domain.tokens.model.warnings.HederaWarnings import com.tangem.domain.tokens.model.warnings.KaspaWarnings import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents @@ -164,6 +165,20 @@ internal class UpdateNotificationsTransformer( ), ), ) + is DynamicAddressesWarnings.FundsFound -> TangemMessageUM( + id = "dynamic_addresses_funds_found", + title = resourceReference(CoreResR.string.dynamic_addresses_notification_funds_found_title), + subtitle = resourceReference(CoreResR.string.dynamic_addresses_notification_funds_found_description), + messageEffect = TangemMessageEffect.None, + iconUM = TangemIconUM.Icon(R.drawable.ic_attention_default_24), + buttonsUM = persistentListOf( + TangemMessageButtonUM( + text = resourceReference(CoreResR.string.common_learn_more), + type = TangemButtonType.Primary, + onClick = clickIntents::onDynamicAddressesFundsFoundLearnMoreClick, + ), + ), + ) // Non-warning types — skip for redesign is CryptoCurrencyWarning.ExistentialDeposit, is CryptoCurrencyWarning.Rent, diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt new file mode 100644 index 0000000000..5fdf8f5045 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetCurrencyWarningsUseCaseTest.kt @@ -0,0 +1,146 @@ +package com.tangem.feature.tokendetails.domain + +import arrow.core.none +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations +import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.tokens.repository.CurrencyChecksRepository +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GetCurrencyWarningsUseCaseTest { + + private val walletManagersFacade: WalletManagersFacade = mockk(relaxed = true) + private val currenciesRepository: CurrenciesRepository = mockk(relaxed = true) + private val currencyChecksRepository: CurrencyChecksRepository = mockk(relaxed = true) + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true) + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true) + private val dynamicAddressesRepository: DynamicAddressesRepository = mockk(relaxed = true) + private val dispatchers: CoroutineDispatcherProvider = TestDispatchers(Dispatchers.Unconfined) + + private val userWalletId: UserWalletId = mockk(relaxed = true) + private val network: Network = mockk(relaxed = true) + private val derivationPath: Network.DerivationPath = mockk(relaxed = true) + private val accountStatusList: AccountStatusList = mockk(relaxed = true) + + private val useCase = GetCurrencyWarningsUseCase( + walletManagersFacade = walletManagersFacade, + currenciesRepository = currenciesRepository, + dispatchers = dispatchers, + currencyChecksRepository = currencyChecksRepository, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + dynamicAddressesRepository = dynamicAddressesRepository, + ) + + @BeforeEach + fun setUp() { + // Bypass coin-related warnings flow: force both coin and token lookups to None so + // the use case falls through to `SomeNetworksUnreachable` without needing real data. + mockkObject(CryptoCurrencyStatusOperations) + with(CryptoCurrencyStatusOperations) { + every { accountStatusList.getCoinStatus(any()) } returns none() + every { accountStatusList.getCryptoCurrencyStatus(any()) } returns none() + } + + every { singleAccountStatusListSupplier(any()) } returns flowOf(accountStatusList) + coEvery { currencyChecksRepository.getRentInfoWarning(any(), any()) } returns null + coEvery { currencyChecksRepository.getExistentialDeposit(any(), any()) } returns null + coEvery { currencyChecksRepository.getFeeResourceAmount(any(), any()) } returns null + coEvery { walletManagersFacade.getAssetRequirements(any(), any()) } returns null + } + + @AfterEach + fun tearDown() { + unmockkObject(CryptoCurrencyStatusOperations) + } + + @Test + fun `GIVEN token currency WHEN invoke THEN FundsFound is absent and probe flow is not queried`() = runTest { + // GIVEN + val token: CryptoCurrency.Token = mockk(relaxed = true) { + every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network + } + val currencyStatus = statusFor(token) + + // WHEN + val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first() + + // THEN + assertThat(result).doesNotContain(DynamicAddressesWarnings.FundsFound) + verify(exactly = 0) { + dynamicAddressesRepository.hasFundsOnAdditionalAddresses(any(), any()) + } + } + + @Test + fun `GIVEN coin currency AND probe emits true WHEN invoke THEN FundsFound is present`() = runTest { + // GIVEN + val coin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network + } + every { dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, network) } returns flowOf(true) + val currencyStatus = statusFor(coin) + + // WHEN + val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first() + + // THEN + assertThat(result).contains(DynamicAddressesWarnings.FundsFound) + } + + @Test + fun `GIVEN coin currency AND probe emits false WHEN invoke THEN FundsFound is absent`() = runTest { + // GIVEN + val coin: CryptoCurrency.Coin = mockk(relaxed = true) { + every { this@mockk.network } returns this@GetCurrencyWarningsUseCaseTest.network + } + every { dynamicAddressesRepository.hasFundsOnAdditionalAddresses(userWalletId, network) } returns flowOf(false) + val currencyStatus = statusFor(coin) + + // WHEN + val result = useCase.invoke(userWalletId, currencyStatus, derivationPath).first() + + // THEN + assertThat(result).doesNotContain(DynamicAddressesWarnings.FundsFound) + } + + private fun statusFor(currency: CryptoCurrency): CryptoCurrencyStatus { + return mockk(relaxed = true) { + every { this@mockk.currency } returns currency + } + } + + private class TestDispatchers(dispatcher: CoroutineDispatcher) : CoroutineDispatcherProvider { + override val main: CoroutineDispatcher = dispatcher + override val mainImmediate: CoroutineDispatcher = dispatcher + override val io: CoroutineDispatcher = dispatcher + override val default: CoroutineDispatcher = dispatcher + override val single: CoroutineDispatcher = dispatcher + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt new file mode 100644 index 0000000000..059dacd1a9 --- /dev/null +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsNotificationConverterTest.kt @@ -0,0 +1,48 @@ +package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents +import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsNotification +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class TokenDetailsNotificationConverterTest { + + private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true) + private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + private val userWalletId: UserWalletId = mockk(relaxed = true) + + private val converter = TokenDetailsNotificationConverter( + userWalletId = userWalletId, + getUserWalletUseCase = getUserWalletUseCase, + clickIntents = clickIntents, + ) + + @Test + fun `GIVEN FundsFound warning WHEN convert THEN DynamicAddressesFundsFound notification is produced`() { + // WHEN + val result = converter.convert(setOf(DynamicAddressesWarnings.FundsFound)) + + // THEN + assertThat(result).hasSize(1) + assertThat(result.first()).isInstanceOf(TokenDetailsNotification.DynamicAddressesFundsFound::class.java) + } + + @Test + fun `GIVEN FundsFound warning WHEN learn more button clicked THEN click intent is invoked`() { + // GIVEN + val notification = converter.convert(setOf(DynamicAddressesWarnings.FundsFound)).first() + val button = notification.config.buttonsState as NotificationConfig.ButtonsState.SecondaryButtonConfig + + // WHEN + button.onClick() + + // THEN + verify(exactly = 1) { clickIntents.onDynamicAddressesFundsFoundLearnMoreClick() } + } +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt index 3cd6df9608..add72c4956 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateNotificationsTransformerTest.kt @@ -7,6 +7,7 @@ import com.tangem.core.ui.ds.message.TangemMessageEffect import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning +import com.tangem.domain.tokens.model.warnings.DynamicAddressesWarnings import com.tangem.domain.tokens.model.warnings.HederaWarnings import com.tangem.domain.tokens.model.warnings.KaspaWarnings import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents @@ -449,6 +450,37 @@ class UpdateNotificationsTransformerTest { verify(exactly = 1) { clickIntents.onDismissIncompleteTransactionClick() } } + @Test + fun `GIVEN DynamicAddressesFundsFound WHEN transform THEN notification with learn more button is created`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(DynamicAddressesWarnings.FundsFound), + ) + + // WHEN + val result = transformer.transform(initialState()) + + // THEN + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first().id).isEqualTo("dynamic_addresses_funds_found") + assertThat(result.notifications.first().buttonsUM).hasSize(1) + } + + @Test + fun `GIVEN DynamicAddressesFundsFound WHEN button clicked THEN onDynamicAddressesFundsFoundLearnMoreClick is called`() { + // GIVEN + val transformer = createTransformer( + warnings = setOf(DynamicAddressesWarnings.FundsFound), + ) + + // WHEN + val result = transformer.transform(initialState()) + result.notifications.first().buttonsUM.first().onClick() + + // THEN + verify(exactly = 1) { clickIntents.onDynamicAddressesFundsFoundLearnMoreClick() } + } + @Test fun `GIVEN MigrationClore WHEN button clicked THEN onCloreMigrationClick is called`() { // GIVEN diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 91a743b450..122418a100 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1496" +tangemBlockchainSdk = "develop-1498" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-602" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 3baed1e81567d0603381e113163dd8a24d99e477 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 24 Apr 2026 09:59:56 +0200 Subject: [PATCH 107/206] Updated on 2026-08-14 --- .../markets/tokenselector}/BalanceColumn.kt | 9 +- .../tokenselector}/GroupedUserAssetItem.kt | 8 +- .../markets/tokenselector}/LayeringIcons.kt | 2 +- .../tokenselector}/SingleUserAssetItem.kt | 4 +- .../TokenSelectorBottomSheet.kt | 87 ++- .../TokenSelectorContentPreviewProvider.kt | 9 +- .../tokenselector/TokenSelectorContentUM.kt | 8 +- .../tokenselector}/TokenSelectorList.kt | 6 +- .../markets/tokenselector/UserAssetItemUM.kt | 61 ++ .../models/portfolio/UserAssetEntry.kt} | 4 +- .../search/model/UserAssetSearchItem.kt | 6 +- .../search/usecase/GetSearchResultsUseCase.kt | 8 +- .../addtoportfolio/AddToPortfolioComponent.kt | 4 +- .../addtoportfolio/AddToPortfolioManager.kt | 30 +- .../common-features/impl/build.gradle.kts | 12 + .../AddToPortfolioBottomSheet.kt | 7 +- .../DefaultAddToPortfolioComponent.kt | 20 +- ...tAddToPortfolioPreselectedDataComponent.kt | 1 + .../di/AddToPortfolioComponentModule.kt | 7 + .../di/AddToPortfolioModelModule.kt | 6 + .../AddToPortfolioInitialSelectionResolver.kt | 114 ++++ .../model/AddToPortfolioModel.kt | 226 ++++++- .../model/AddToPortfolioRoutes.kt | 3 + .../ui/DefaultAddToPortfolioManager.kt | 5 + .../DefaultUserPortfolioComponent.kt | 47 ++ .../userportfolio/UserPortfolioComponent.kt | 20 + .../userportfolio/model/UserPortfolioModel.kt | 20 + .../userportfolio/model/UserPortfolioUM.kt | 10 + .../state/UserPortfolioStateController.kt | 57 ++ .../UserPortfolioSectionsTransformer.kt | 187 ++++++ ...ToPortfolioInitialSelectionResolverTest.kt | 591 ++++++++++++++++++ .../feed/components/FeedEntryChildFactory.kt | 3 + .../market/details/AddToPortfolioSlotRoute.kt | 7 + .../DefaultMarketsTokenDetailsComponent.kt | 44 +- .../portfolioblock/PortfolioBlockComponent.kt | 87 +-- .../PortfolioBlockParentClickIntents.kt | 8 + .../model/PortfolioBlockModel.kt | 50 +- .../model/PortfolioBlockRoute.kt | 7 - .../portfolioblock/ui/PortfolioBlock.kt | 113 ++-- .../ui/state/PortfolioBlockUM.kt | 5 +- .../search/SearchBottomSheetRoute.kt | 6 +- .../search/SearchTokenSelectorComponent.kt | 8 +- .../details/MarketsTokenDetailsModel.kt | 90 ++- .../features/feed/model/search/SearchModel.kt | 6 +- .../model/search/SearchTokenSelectorModel.kt | 2 +- .../converter/UserAssetSearchItemConverter.kt | 22 +- .../state/TokenSelectorStateController.kt | 2 +- .../BuildTokenSelectorSectionsTransformer.kt | 12 +- .../TokenSelectorEntryConverter.kt | 12 +- .../TokenSelectorUMTransformer.kt | 2 +- .../UpdateUserAssetsTransformer.kt | 2 +- .../features/feed/ui/search/SearchContent.kt | 5 +- .../ui/search/preview/SearchContentPreview.kt | 2 + .../ui/search/preview/UserAssetItemPreview.kt | 8 +- .../features/feed/ui/search/state/SearchUM.kt | 61 +- ...ildTokenSelectorSectionsTransformerTest.kt | 14 +- .../TokenSelectorEntryConverterTest.kt | 10 +- 57 files changed, 1797 insertions(+), 370 deletions(-) rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector}/BalanceColumn.kt (96%) rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector}/GroupedUserAssetItem.kt (91%) rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector}/LayeringIcons.kt (98%) rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector}/SingleUserAssetItem.kt (95%) rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector}/TokenSelectorBottomSheet.kt (63%) rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector}/TokenSelectorContentPreviewProvider.kt (90%) rename features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentUM.kt (80%) rename {features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components => common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector}/TokenSelectorList.kt (94%) create mode 100644 common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/UserAssetItemUM.kt rename domain/{search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt => models/src/main/kotlin/com/tangem/domain/models/portfolio/UserAssetEntry.kt} (87%) create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/UserPortfolioComponent.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioModel.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioUM.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/state/UserPortfolioStateController.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/transformer/UserPortfolioSectionsTransformer.kt create mode 100644 features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/AddToPortfolioSlotRoute.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockParentClickIntents.kt delete mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/BalanceColumn.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/BalanceColumn.kt similarity index 96% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/BalanceColumn.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/BalanceColumn.kt index 552f07fcb3..36625d5b13 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/BalanceColumn.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/BalanceColumn.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.search.components +package com.tangem.common.ui.markets.tokenselector import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon @@ -17,15 +17,10 @@ import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.feed.ui.search.state.BalanceDisplayState import com.tangem.utils.StringsSigns @Composable -internal fun BalanceColumn( - balanceState: BalanceDisplayState, - isBalanceHidden: Boolean, - modifier: Modifier = Modifier, -) { +fun BalanceColumn(balanceState: BalanceDisplayState, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { if (isBalanceHidden) { HiddenBalance(modifier) return diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/GroupedUserAssetItem.kt similarity index 91% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/GroupedUserAssetItem.kt index 11504f43d5..26115886e6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/GroupedUserAssetItem.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/GroupedUserAssetItem.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.search.components +package com.tangem.common.ui.markets.tokenselector import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -10,18 +10,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layoutId import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.R import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.row.TangemRowContainer import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.pluralStringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.components.LayeringIcons -import com.tangem.features.feed.ui.search.state.UserAssetItemUM @Composable -internal fun GroupedUserAssetItem(item: UserAssetItemUM.Grouped, modifier: Modifier = Modifier) { +fun GroupedUserAssetItem(item: UserAssetItemUM.Grouped, modifier: Modifier = Modifier) { Box( modifier = modifier.background( color = TangemTheme.colors2.surface.level3, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/LayeringIcons.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/LayeringIcons.kt similarity index 98% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/LayeringIcons.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/LayeringIcons.kt index 1236344817..2ca0775133 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/LayeringIcons.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/LayeringIcons.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.components +package com.tangem.common.ui.markets.tokenselector import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt similarity index 95% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt index dd388ce1c5..62d8e486f5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/SingleUserAssetItem.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/SingleUserAssetItem.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.search.components +package com.tangem.common.ui.markets.tokenselector import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.clickable @@ -17,8 +17,6 @@ import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.ds.row.TangemRowContainer import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.feed.ui.search.state.BalanceDisplayState -import com.tangem.features.feed.ui.search.state.UserAssetItemUM @Composable fun SingleUserAssetItem(shouldUsePriceBlock: Boolean, item: UserAssetItemUM.Single, modifier: Modifier = Modifier) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt similarity index 63% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt index 36196001f9..b9d7920386 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorBottomSheet.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.search.components +package com.tangem.common.ui.markets.tokenselector import android.content.res.Configuration import androidx.compose.animation.core.EaseOut @@ -25,59 +25,114 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.features.feed.ui.search.preview.TokenSelectorContentPreviewProvider -import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.rememberHazeState @Composable -internal fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig) { +fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig, stickyFooter: StickyFooter? = null) { TangemBottomSheet( config = config, type = TangemBottomSheetType.Modal, containerColor = TangemTheme.colors2.surface.level2, content = { content -> - TokenSelectorContent(content, config.onDismissRequest) + TokenSelectorContent( + content = content, + onDismiss = config.onDismissRequest, + stickyFooter = stickyFooter, + embedded = false, + ) }, ) } +data class StickyFooter(val buttonText: TextReference, val isEnabled: Boolean = true, val onClick: () -> Unit) + @Composable -private fun TokenSelectorContent(content: TokenSelectorContentUM, onDismiss: () -> Unit) { +fun TokenSelectorEmbeddedContent( + content: TokenSelectorContentUM, + stickyFooter: StickyFooter?, + modifier: Modifier = Modifier, +) { + TokenSelectorContent( + content = content, + onDismiss = {}, + stickyFooter = stickyFooter, + embedded = true, + modifier = modifier, + ) +} + +@Composable +private fun TokenSelectorContent( + content: TokenSelectorContentUM, + onDismiss: () -> Unit, + stickyFooter: StickyFooter?, + embedded: Boolean, + modifier: Modifier = Modifier, +) { val hazeState = rememberHazeState() var topBarHeight by remember { mutableStateOf(0.dp) } + val topContentPadding = if (embedded) { + TangemTheme.dimens2.x4 + } else { + topBarHeight + } + val footerHeight = TangemTheme.dimens2.x14 + TangemTheme.dimens2.x4 + val listBottomPadding = TangemTheme.dimens2.x10 + if (stickyFooter != null) footerHeight else 0.dp - Box(modifier = Modifier.fillMaxWidth()) { + Box(modifier = modifier.fillMaxWidth()) { LazyColumn( modifier = Modifier.hazeSourceTangem(state = hazeState, 1f), contentPadding = PaddingValues( start = TangemTheme.dimens2.x4, end = TangemTheme.dimens2.x4, - top = topBarHeight, - bottom = TangemTheme.dimens2.x10, + top = topContentPadding, + bottom = listBottomPadding, ), ) { tokenSelectorSectionItems(content.sections) } - TokenSelectorSheetTopBar( - modifier = Modifier.align(Alignment.TopEnd), - onDismiss = onDismiss, - hazeState = hazeState, - onChangeHeight = { topBarHeight = it }, - ) + if (!embedded) { + TokenSelectorSheetTopBar( + modifier = Modifier.align(Alignment.TopEnd), + onDismiss = onDismiss, + hazeState = hazeState, + onChangeHeight = { topBarHeight = it }, + ) + } Fade( modifier = Modifier .fillMaxWidth() - .align(Alignment.BottomCenter), + .align(Alignment.BottomCenter) + .padding(bottom = if (stickyFooter != null) footerHeight else 0.dp), height = TangemTheme.dimens2.x10, ) + if (stickyFooter != null) { + TangemButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2) + .fillMaxWidth(), + buttonUM = TangemButtonUM( + type = TangemButtonType.Primary, + text = stickyFooter.buttonText, + shape = TangemButtonShape.Rounded, + size = TangemButtonSize.X15, + isEnabled = stickyFooter.isEnabled, + onClick = stickyFooter.onClick, + ), + ) + } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentPreviewProvider.kt similarity index 90% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentPreviewProvider.kt index 2a1922d067..d6301236d5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/TokenSelectorContentPreviewProvider.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentPreviewProvider.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.search.preview +package com.tangem.common.ui.markets.tokenselector import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import com.tangem.core.ui.R @@ -8,15 +8,10 @@ import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.features.feed.ui.search.state.AccountHeaderData -import com.tangem.features.feed.ui.search.state.BalanceDisplayState -import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM -import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM -import com.tangem.features.feed.ui.search.state.UserAssetItemUM import kotlinx.collections.immutable.persistentListOf @Suppress("StringLiteralDuplication") -internal class TokenSelectorContentPreviewProvider : +class TokenSelectorContentPreviewProvider : CollectionPreviewParameterProvider( listOf( tokenSelectorPreviewSimple(), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentUM.kt similarity index 80% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentUM.kt index 3268d7d2c8..55994b5a72 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/TokenSelectorUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorContentUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.search.state +package com.tangem.common.ui.markets.tokenselector import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent @@ -7,18 +7,18 @@ import com.tangem.domain.models.account.CryptoPortfolioIcon import kotlinx.collections.immutable.ImmutableList @Immutable -internal data class TokenSelectorContentUM( +data class TokenSelectorContentUM( val sections: ImmutableList, ) : TangemBottomSheetConfigContent @Immutable -internal data class AccountHeaderData( +data class AccountHeaderData( val accountName: TextReference, val cryptoPortfolioIcon: CryptoPortfolioIcon, ) @Immutable -internal sealed interface TokenSelectorSectionUM { +sealed interface TokenSelectorSectionUM { data class WalletHeader(val walletName: String) : TokenSelectorSectionUM diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorList.kt similarity index 94% rename from features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt rename to common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorList.kt index 1e96099720..eddbe4fc8e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/components/TokenSelectorList.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorList.kt @@ -1,4 +1,4 @@ -package com.tangem.features.feed.ui.search.components +package com.tangem.common.ui.markets.tokenselector import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -21,11 +21,9 @@ import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.feed.ui.search.state.AccountHeaderData -import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM import kotlinx.collections.immutable.ImmutableList -internal fun LazyListScope.tokenSelectorSectionItems(sections: ImmutableList) { +fun LazyListScope.tokenSelectorSectionItems(sections: ImmutableList) { sections.forEachIndexed { index, section -> when (section) { is TokenSelectorSectionUM.WalletHeader -> { diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/UserAssetItemUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/UserAssetItemUM.kt new file mode 100644 index 0000000000..1729f70d6c --- /dev/null +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/UserAssetItemUM.kt @@ -0,0 +1,61 @@ +package com.tangem.common.ui.markets.tokenselector + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.TextReference + +@Immutable +sealed interface BalanceDisplayState { + + data class Loaded( + val cryptoBalance: TextReference, + val fiatBalance: TextReference, + ) : BalanceDisplayState + + data class Flickering( + val cryptoBalance: TextReference, + val fiatBalance: TextReference, + ) : BalanceDisplayState + + data class Stale( + val cryptoBalance: TextReference, + val fiatBalance: TextReference, + ) : BalanceDisplayState + + data object Loading : BalanceDisplayState + data object Unreachable : BalanceDisplayState +} + +@Immutable +sealed interface UserAssetItemUM { + val id: String + val icon: TangemIconUM + val tokenName: String + val tokenSymbol: String + val onClick: () -> Unit + + data class Single( + override val id: String, + override val icon: TangemIconUM, + override val tokenName: String, + override val tokenSymbol: String, + val fiatRate: String?, + val priceChangeState: PriceChangeState, + val balanceState: BalanceDisplayState, + val isBalanceHidden: Boolean, + val networkName: String, + override val onClick: () -> Unit, + ) : UserAssetItemUM + + data class Grouped( + override val id: String, + override val icon: TangemIconUM, + override val tokenName: String, + override val tokenSymbol: String, + val tokensCount: Int, + val balanceState: BalanceDisplayState, + val isBalanceHidden: Boolean, + override val onClick: () -> Unit, + ) : UserAssetItemUM +} \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/portfolio/UserAssetEntry.kt similarity index 87% rename from domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt rename to domain/models/src/main/kotlin/com/tangem/domain/models/portfolio/UserAssetEntry.kt index c6a3d45854..661f192008 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchEntry.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/portfolio/UserAssetEntry.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.search.model +package com.tangem.domain.models.portfolio import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountName @@ -6,7 +6,7 @@ import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId -data class UserAssetSearchEntry( +data class UserAssetEntry( val userWalletId: UserWalletId, val userWalletName: String, val accountId: AccountId, diff --git a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt index 7a63289486..86387af79a 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/model/UserAssetSearchItem.kt @@ -1,13 +1,15 @@ package com.tangem.domain.search.model +import com.tangem.domain.models.portfolio.UserAssetEntry + sealed interface UserAssetSearchItem { - data class Single(val entry: UserAssetSearchEntry) : UserAssetSearchItem + data class Single(val entry: UserAssetEntry) : UserAssetSearchItem data class Grouped( val tokenName: String, val tokenSymbol: String, val tokenIconUrl: String?, - val entries: List, + val entries: List, ) : UserAssetSearchItem } \ No newline at end of file diff --git a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt index 0b6b37be48..ba5da04383 100644 --- a/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt +++ b/domain/search/src/main/java/com/tangem/domain/search/usecase/GetSearchResultsUseCase.kt @@ -8,7 +8,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.search.model.SearchResult -import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.models.portfolio.UserAssetEntry import com.tangem.domain.search.model.UserAssetSearchItem import com.tangem.domain.search.repository.SearchRepository import kotlinx.coroutines.flow.Flow @@ -97,7 +97,7 @@ class GetSearchResultsUseCase( return totalAccounts > 1 } - private fun groupAndSort(entries: List, shouldGroup: Boolean): List { + private fun groupAndSort(entries: List, shouldGroup: Boolean): List { if (!shouldGroup) { return entries .map { UserAssetSearchItem.Single(it) } @@ -129,7 +129,7 @@ class GetSearchResultsUseCase( statusList: AccountStatusList, wallets: Map, lowerQuery: String, - ): List { + ): List { val wallet = wallets[statusList.userWalletId] ?: return emptyList() return statusList.accountStatuses .filterCryptoPortfolio() @@ -141,7 +141,7 @@ class GetSearchResultsUseCase( name.contains(lowerQuery) || symbol.contains(lowerQuery) } .map { currencyStatus -> - UserAssetSearchEntry( + UserAssetEntry( userWalletId = statusList.userWalletId, userWalletName = wallet.name, accountId = accountStatus.accountId, diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt index 2261f0335d..d68a5f6d19 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioComponent.kt @@ -5,9 +5,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent interface AddToPortfolioComponent : ComposableBottomSheetComponent { - data class Params( - val addToPortfolioManager: AddToPortfolioManager, - ) + data class Params(val addToPortfolioManager: AddToPortfolioManager) interface Factory : ComponentFactory } \ No newline at end of file diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt index e1d58b5033..70e41a3756 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt @@ -3,6 +3,7 @@ package com.tangem.features.commonfeatures.api.addtoportfolio import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher @@ -18,6 +19,7 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal { val onDismiss: Channel val onSuccessAdded: Channel + val onAddedTokenClick: Channel val portfolioFetcher: PortfolioFetcher val state: StateFlow @@ -27,36 +29,34 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal { sealed interface State { data object Loading : State - data class Ready( - val availableToAddData: AvailableToAddData, - ) : State { + data class Ready(val availableToAddData: AvailableToAddData) : State { val isAvailableToAdd: Boolean get() = availableToAddData.isAvailableToAdd val isSinglePortfolio: Boolean get() = availableToAddData.isSinglePortfolio } } @Serializable - data class AnalyticsParams( - val source: String?, - ) + data class AnalyticsParams(val source: String?) interface Factory { fun create(scope: CoroutineScope, settings: Settings, analyticsParams: AnalyticsParams): AddToPortfolioManager } + sealed interface LaunchMode { + data object DirectAdd : LaunchMode + data class ViaUserPortfolio(val rawCurrencyId: CryptoCurrency.RawID) : LaunchMode + } + /** * Immutable settings */ data class Settings( val shouldSkipTokenActionsScreen: Boolean = false, + val launchMode: LaunchMode = LaunchMode.DirectAdd, ) { companion object { - val DefaultMarket = Settings( - shouldSkipTokenActionsScreen = false, - ) - val ChooseToken = Settings( - shouldSkipTokenActionsScreen = true, - ) + val DefaultMarket = Settings(shouldSkipTokenActionsScreen = false) + val ChooseToken = Settings(shouldSkipTokenActionsScreen = true) } } @@ -64,10 +64,7 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal { * Mutable parameters * Updates may trigger reload [State] */ - data class Params( - val networks: List, - val token: TokenMarketParams, - ) + data class Params(val networks: List, val token: TokenMarketParams) data class Result( val wallet: UserWallet, @@ -88,4 +85,5 @@ interface AddToPortfolioManagerInternal { fun onDismiss() fun onSuccessAdded(result: AddToPortfolioManager.Result) + fun onAddedTokenClick(result: AddToPortfolioManager.Result) } \ No newline at end of file diff --git a/features/common-features/impl/build.gradle.kts b/features/common-features/impl/build.gradle.kts index afa9fcfb58..4632310a53 100644 --- a/features/common-features/impl/build.gradle.kts +++ b/features/common-features/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.commonfeatures.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Api */ implementation(projects.features.commonFeatures.api) @@ -56,6 +60,9 @@ dependencies { implementation(projects.common.uiMarkets) implementation(projects.common.routing) + /** Libs */ + implementation(projects.libs.blockchainSdk) + /** AndroidX libraries */ implementation(deps.androidx.core.ktx) implementation(deps.lifecycle.runtime.ktx) @@ -79,4 +86,9 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(projects.common.test) + testImplementation(projects.test.core) + testImplementation(projects.test.mock) } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt index 0a20db5947..3f2288d1f4 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheet.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.tangem.features.commonfeatures.impl.R import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig @@ -21,6 +20,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent +import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes @Composable @@ -63,7 +63,9 @@ internal fun AddToPortfolioBottomSheet( bottom = 16.dp, ) val isScrollableContent = when (animatedStack.active.configuration) { - AddToPortfolioRoutes.PortfolioSelector -> false + AddToPortfolioRoutes.PortfolioSelector, + AddToPortfolioRoutes.UserPortfolio, + -> false AddToPortfolioRoutes.AddToken, AddToPortfolioRoutes.Empty, is AddToPortfolioRoutes.NetworkSelector, @@ -95,6 +97,7 @@ private fun AddToPortfolioBottomSheetTitle( AddToPortfolioRoutes.Empty -> TextReference.EMPTY is AddToPortfolioRoutes.NetworkSelector -> resourceReference(R.string.common_choose_network) AddToPortfolioRoutes.TokenActions -> resourceReference(R.string.common_get_token) + AddToPortfolioRoutes.UserPortfolio -> resourceReference(R.string.markets_portfolio_block_title) AddToPortfolioRoutes.PortfolioSelector -> (stack.active.instance as PortfolioSelectorComponent) .title.collectAsStateWithLifecycle().value } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt index d69a90e75d..214ba365f5 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt @@ -11,14 +11,17 @@ import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +@Suppress("LongParameterList") internal class DefaultAddToPortfolioComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: AddToPortfolioComponent.Params, @@ -26,6 +29,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( addTokenComponentFactory: AddTokenComponent.Factory, tokenActionsComponentFactory: TokenActionsComponent.Factory, private val chooseNetworkComponentFactory: ChooseNetworkComponent.Factory, + private val userPortfolioComponentFactory: UserPortfolioComponent.Factory, ) : AppComponentContext by context, AddToPortfolioComponent { private val model: AddToPortfolioModel = getOrCreateModel(params) @@ -91,6 +95,7 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent AddToPortfolioRoutes.TokenActions -> tokenActionsComponent AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY + AddToPortfolioRoutes.UserPortfolio -> createUserPortfolioComponent(componentContext) is AddToPortfolioRoutes.NetworkSelector -> chooseNetworkComponentFactory.create( context = childByContext(componentContext), params = ChooseNetworkComponent.Params( @@ -100,6 +105,19 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( ) } + private fun createUserPortfolioComponent(componentContext: ComponentContext): ComposableContentComponent { + return when (model.addToPortfolioManager.settings.launchMode) { + AddToPortfolioManager.LaunchMode.DirectAdd -> ComposableContentComponent.EMPTY + is AddToPortfolioManager.LaunchMode.ViaUserPortfolio -> userPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = UserPortfolioComponent.Params( + uiState = model.userPortfolioStateController.uiState, + callbacks = model, + ), + ) + } + } + @AssistedFactory interface Factory : AddToPortfolioComponent.Factory { override fun create( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt index 7ba255d6e8..bb72f99975 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt @@ -79,6 +79,7 @@ internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject con AddToPortfolioRoutes.TokenActions -> ComposableContentComponent.EMPTY AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY is AddToPortfolioRoutes.NetworkSelector -> ComposableContentComponent.EMPTY + AddToPortfolioRoutes.UserPortfolio -> ComposableContentComponent.EMPTY } @AssistedFactory diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt index 871d7f7c44..c32baf5b2f 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt @@ -6,6 +6,8 @@ import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPrese import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioPreselectedDataComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.DefaultAddToPortfolioManager +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.DefaultUserPortfolioComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -25,4 +27,9 @@ internal interface AddToPortfolioComponentModule { fun bindAddToPortfolioPreselectedDataComponent( factory: DefaultAddToPortfolioPreselectedDataComponent.Factory, ): AddToPortfolioPreselectedDataComponent.Factory + + @Binds + fun bindUserPortfolioComponentFactory( + factory: DefaultUserPortfolioComponent.Factory, + ): UserPortfolioComponent.Factory } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt index d0e2845d69..a0ab0d1bf4 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt @@ -7,6 +7,7 @@ import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfol import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -41,4 +42,9 @@ internal interface AddToPortfolioModelModule { @IntoMap @ClassKey(ChooseNetworkModel::class) fun chooseNetworkModel(model: ChooseNetworkModel): Model + + @Binds + @IntoMap + @ClassKey(UserPortfolioModel::class) + fun userPortfolioModel(model: UserPortfolioModel): Model } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt new file mode 100644 index 0000000000..8db0647303 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt @@ -0,0 +1,114 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.model + +import arrow.core.getOrElse +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet +import javax.inject.Inject + +internal class AddToPortfolioInitialSelectionResolver @Inject constructor( + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, + private val networkHasDerivationUseCase: NetworkHasDerivationUseCase, +) { + + suspend fun resolve( + availableToAddData: AvailableToAddData, + orderedNetworks: List, + selectedWallet: UserWallet?, + tokenParams: TokenMarketParams, + accountToAdd: AvailableToAddAccount? = null, + ): InitialSelection? { + if (availableToAddData.availableToAddWallets.isEmpty()) return null + val fallbackNetwork = orderedNetworks.firstOrNull() ?: return null + + val walletOrder = orderedWallets(availableToAddData, selectedWallet) + + if (accountToAdd != null) { + val ownerWallet = walletOrder.firstOrNull { entry -> + entry.availableToAddAccounts.values.any { it === accountToAdd } + } ?: walletOrder.first() + val network = pickAddableNetwork( + userWallet = ownerWallet.userWallet, + account = accountToAdd, + orderedNetworks = orderedNetworks, + tokenParams = tokenParams, + ) + ?: fallbackNetwork + return InitialSelection(userWallet = ownerWallet.userWallet, account = accountToAdd, network = network) + } + + for (walletEntry in walletOrder) { + val account = pickAvailableAccount(walletEntry) ?: continue + val network = pickAddableNetwork( + userWallet = walletEntry.userWallet, + account = account, + orderedNetworks = orderedNetworks, + tokenParams = tokenParams, + ) ?: continue + return InitialSelection(walletEntry.userWallet, account, network) + } + + val fallbackWallet = walletOrder.first() + val fallbackAccount = pickFallbackAccount(fallbackWallet) ?: return null + return InitialSelection(fallbackWallet.userWallet, fallbackAccount, fallbackNetwork) + } + + private fun orderedWallets(data: AvailableToAddData, selectedWallet: UserWallet?): List { + val preferred = selectedWallet?.walletId?.let { data.availableToAddWallets[it] } + return buildList { + preferred?.let(::add) + data.availableToAddWallets.values.forEach { entry -> + if (entry !== preferred) add(entry) + } + } + } + + private fun pickAvailableAccount(walletEntry: AvailableToAddWallet): AvailableToAddAccount? { + val mainId = AccountId.forMainCryptoPortfolio(walletEntry.userWallet.walletId) + return walletEntry.availableToAddAccounts[mainId]?.takeIf { it.isAvailableToAdd } + ?: walletEntry.availableToAddAccounts.values.firstOrNull { it.isAvailableToAdd } + } + + private fun pickFallbackAccount(walletEntry: AvailableToAddWallet): AvailableToAddAccount? { + val mainId = AccountId.forMainCryptoPortfolio(walletEntry.userWallet.walletId) + return walletEntry.availableToAddAccounts[mainId] + ?: walletEntry.availableToAddAccounts.values.firstOrNull() + } + + private suspend fun pickAddableNetwork( + userWallet: UserWallet, + account: AvailableToAddAccount, + orderedNetworks: List, + tokenParams: TokenMarketParams, + ): TokenMarketInfo.Network? { + val availableOrdered = orderedNetworks.filter { candidate -> + account.availableToAddNetworks.any { it.networkId == candidate.networkId } + } + if (availableOrdered.isEmpty()) return null + + val derivationIndex = account.account.account.derivationIndex + val withDerivation = availableOrdered.filter { network -> + val currency = getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = tokenParams, + network = network, + accountIndex = derivationIndex, + ) ?: return@filter false + networkHasDerivationUseCase(userWallet, currency.network).getOrElse { false } + } + + return withDerivation.firstOrNull() ?: availableOrdered.first() + } + + data class InitialSelection( + val userWallet: UserWallet, + val account: AvailableToAddAccount, + val network: TokenMarketInfo.Network, + ) +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 18071b731c..3749d47c9d 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -4,6 +4,7 @@ import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.popToFirst import com.arkivanov.decompose.router.stack.pushNew import com.arkivanov.decompose.router.stack.replaceAll +import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network import com.tangem.common.ui.markets.action.CryptoCurrencyData import com.tangem.common.ui.markets.action.QuickActionsConverter.toQuickActions import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -11,22 +12,29 @@ 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.ui.UiMessageSender +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 import com.tangem.domain.markets.GetTokenMarketCryptoCurrency import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.* import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController -import com.tangem.features.commonfeatures.api.addtoportfolio.* import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.ChooseNetworkComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state.UserPortfolioStateController import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.Job @@ -40,20 +48,25 @@ import javax.inject.Inject private const val TOKEN_ACTIONS_DELAY = 500L @ModelScoped -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class AddToPortfolioModel @Inject constructor( paramsContainer: ParamsContainer, + designFeatureToggles: DesignFeatureToggles, override val dispatchers: CoroutineDispatcherProvider, + val portfolioSelectorController: PortfolioSelectorController, private val callbackDelegate: AddToPortfolioCallbackDelegate, private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, private val messageSender: UiMessageSender, private val analyticsEventHandler: AnalyticsEventHandler, - val portfolioSelectorController: PortfolioSelectorController, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val selectionResolver: AddToPortfolioInitialSelectionResolver, + userPortfolioStateControllerFactory: UserPortfolioStateController.Factory, ) : Model(), ChooseNetworkComponent.Callbacks by callbackDelegate, TokenActionsComponent.Callbacks by callbackDelegate, - AddTokenComponent.Callbacks by callbackDelegate { + AddTokenComponent.Callbacks by callbackDelegate, + UserPortfolioComponent.Callbacks by callbackDelegate { private val params = paramsContainer.require() val navigation = StackNavigation() @@ -68,11 +81,24 @@ internal class AddToPortfolioModel @Inject constructor( val portfolioFetcher: PortfolioFetcher = addToPortfolioManager.portfolioFetcher val eventBuilder: MutableSharedFlow = replayMutableSharedFlow() + val userPortfolioStateController = userPortfolioStateControllerFactory.create( + modelScope = modelScope, + onTokenSelected = { result -> addToPortfolioManager.onAddedTokenClick(result) }, + ) + val featureData: Flow = combineFeatureData() + private val globalSelectedWallet: UserWallet? + get() = getSelectedWalletSyncUseCase().getOrNull() + .takeIf { it?.isMultiCurrency == true } + init { navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } - startAddToPortfolioFlow() + if (designFeatureToggles.isRedesignEnabled) { + startRedesignAddToPortfolioFlow() + } else { + startLegacyAddToPortfolioFlow() + } } private fun replayMutableSharedFlow() = MutableSharedFlow( @@ -81,7 +107,7 @@ internal class AddToPortfolioModel @Inject constructor( ) @Suppress("LongMethod") - private fun startAddToPortfolioFlow() { + private fun startLegacyAddToPortfolioFlow() { channelFlow { fun finishSuccessFlow(result: AddToPortfolioManager.Result) { addToPortfolioManager.onSuccessAdded(result) @@ -208,6 +234,130 @@ internal class AddToPortfolioModel @Inject constructor( .launchIn(modelScope) } + @Suppress("LongMethod") + private fun startRedesignAddToPortfolioFlow() { + channelFlow { + fun finishSuccessFlow(result: AddToPortfolioManager.Result) { + addToPortfolioManager.onSuccessAdded(result) + channel.close() + } + + fun finishDismissFlow() { + addToPortfolioManager.onDismiss() + channel.close() + } + + val tokenMarketParams = addToPortfolioManager.paramsFlow.first().token + val eb = PortfolioAnalyticsEvent.EventBuilder( + tokenSymbol = tokenMarketParams.symbol, + source = addToPortfolioManager.analyticsParams.source, + ) + eventBuilder.tryEmit(eb) + + val launchMode = addToPortfolioManager.settings.launchMode + val initialData = featureData + .filterIsInstance() + .map { it.availableToAddData } + .first() + + if (launchMode is AddToPortfolioManager.LaunchMode.ViaUserPortfolio && + initialData.hasAnyAddedCurrency(launchMode.rawCurrencyId) + ) { + // suspend, must prepare UM before navigate to UserPortfolio + userPortfolioStateController.updateAndWaitNotNullState(initialData, launchMode.rawCurrencyId) + navigation.replaceAll(AddToPortfolioRoutes.UserPortfolio) + callbackDelegate.onContinueFromUserPortfolio.receiveAsFlow().first() + } + + val paramsSnapshot = addToPortfolioManager.paramsFlow.first() + val selection = selectionResolver.resolve( + availableToAddData = initialData, + orderedNetworks = paramsSnapshot.networks, + selectedWallet = globalSelectedWallet, + tokenParams = tokenMarketParams, + ) ?: run { + finishDismissFlow() + return@channelFlow + } + + val isAccountMode = portfolioSelectorController.isAccountModeSync() + portfolioSelectorController.selectAccount(selection.account.account.accountId) + + val firstSelectedPortfolio = SelectedPortfolio( + isAccountMode = isAccountMode, + userWallet = selection.userWallet, + account = selection.account, + isAvailableMorePortfolio = !initialData.isSinglePortfolio, + ) + val firstSelectedNetwork = selection.toSelectedNetwork() ?: run { + finishDismissFlow() + return@channelFlow + } + + selectedPortfolio.emit(firstSelectedPortfolio) + selectedNetwork.emit(firstSelectedNetwork) + + analyticsEventHandler.send(event = eventBuilder.first().popupToConfirm()) + navigation.replaceAll(AddToPortfolioRoutes.AddToken) + + var middleNavigationJob: Job? = null + callbackDelegate.onChangeNetworkClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changeNetworkNavigationFlow() + .launchIn(this) + val route = routeToNetworkSelector(selectedPortfolio.first()) + navigation.pushNew(route) + } + .launchIn(this) + + callbackDelegate.onChangePortfolioClick.receiveAsFlow() + .onEach { + middleNavigationJob?.cancel() + middleNavigationJob = changePortfolioNavigationNewFlow( + data = initialData, + orderedNetworks = paramsSnapshot.networks, + tokenParams = tokenMarketParams, + ).launchIn(this) + logAccountSelector(isAccountMode) + navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) + } + .launchIn(this) + + val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() + middleNavigationJob?.cancel() + val selectedPortfolioSnapshot = selectedPortfolio.first() + val result = AddToPortfolioManager.Result( + wallet = selectedPortfolioSnapshot.userWallet, + account = selectedPortfolioSnapshot.account.account, + addedCurrency = addedToken, + ) + + messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) + + if (addToPortfolioManager.settings.shouldSkipTokenActionsScreen) { + finishSuccessFlow(result) + return@channelFlow + } + + setupTokenActionsFlow(selectedPortfolioSnapshot, addedToken) + .onEach { cryptoCurrencyData -> + tokenActionsData.emit(cryptoCurrencyData) + navigation.replaceAll(AddToPortfolioRoutes.TokenActions) + } + .onEmpty { finishSuccessFlow(result) } + .launchIn(this) + + callbackDelegate.onLaterClick.receiveAsFlow().first() + finishSuccessFlow(result) + } + .catch { throwable -> + TangemLogger.e("Error", throwable) + addToPortfolioManager.onDismiss() + } + .launchIn(modelScope) + } + private suspend fun logAccountSelector(isAccountMode: Boolean) { if (isAccountMode) { analyticsEventHandler.send(eventBuilder.first().popupToChooseAccount()) @@ -249,6 +399,38 @@ internal class AddToPortfolioModel @Inject constructor( ).collect { emit(it) } } + private fun changePortfolioNavigationNewFlow( + data: AvailableToAddData, + orderedNetworks: List, + tokenParams: TokenMarketParams, + ): Flow { + return setupPortfolioFlow(data) + // drop first selected portfolio or any selected before + .drop(1) + .map { newPortfolio -> + val currentNetwork = selectedNetwork.first().selectedNetwork + val availableToAddNetworks = newPortfolio.account.availableToAddNetworks + val isSelectedNetworkAvailableForNewPortfolio = availableToAddNetworks + .any { it.networkId == currentNetwork.networkId } + + if (!isSelectedNetworkAvailableForNewPortfolio) { + val selection = selectionResolver.resolve( + availableToAddData = data, + orderedNetworks = orderedNetworks, + selectedWallet = globalSelectedWallet, + tokenParams = tokenParams, + accountToAdd = newPortfolio.account, + ) + val newNetwork = selection?.toSelectedNetwork() + if (newNetwork != null) { + this.selectedNetwork.tryEmit(newNetwork) + } + } + selectedPortfolio.tryEmit(newPortfolio) + navigation.popToFirst() + } + } + private fun setupTokenActionsFlow( selectedPortfolio: SelectedPortfolio, addedToken: CryptoCurrencyStatus, @@ -350,19 +532,34 @@ internal class AddToPortfolioModel @Inject constructor( -> Unit } } + + private suspend fun AddToPortfolioInitialSelectionResolver.InitialSelection.toSelectedNetwork(): SelectedNetwork? { + val crypto = createCryptoCurrency( + userWallet = userWallet, + network = network, + account = account, + ) ?: return null + return SelectedNetwork( + cryptoCurrency = crypto, + selectedNetwork = network, + isAvailableMoreNetwork = !account.isSingleNetwork, + ) + } } @ModelScoped internal class AddToPortfolioCallbackDelegate @Inject constructor() : ChooseNetworkComponent.Callbacks, TokenActionsComponent.Callbacks, - AddTokenComponent.Callbacks { + AddTokenComponent.Callbacks, + UserPortfolioComponent.Callbacks { val onNetworkSelected = Channel() val onLaterClick = Channel() val onChangeNetworkClick = Channel() val onChangePortfolioClick = Channel() val onTokenAdded = Channel() + val onContinueFromUserPortfolio = Channel() override fun onNetworkSelected(network: TokenMarketInfo.Network) { onNetworkSelected.trySend(network) @@ -383,4 +580,19 @@ internal class AddToPortfolioCallbackDelegate @Inject constructor() : override fun onTokenAdded(status: CryptoCurrencyStatus) { onTokenAdded.trySend(status) } + + override fun onContinueFromUserPortfolio() { + onContinueFromUserPortfolio.trySend(Unit) + } +} + +private fun AvailableToAddData.hasAnyAddedCurrency(rawCurrencyId: CryptoCurrency.RawID): Boolean { + return availableToAddWallets.values.any { wallet -> + wallet.accounts.filterCryptoPortfolio().any { accountStatus -> + accountStatus.tokenList.flattenCurrencies().any { status -> + val id = status.currency.id.rawCurrencyId ?: return@any false + getTokenIdIfL2Network(id.value) == rawCurrencyId.value + } + } + } } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt index e548f5b16f..8c01391c47 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRoutes.kt @@ -23,6 +23,9 @@ internal sealed interface AddToPortfolioRoutes : Route { @Serializable data object AddToken : AddToPortfolioRoutes + @Serializable + data object UserPortfolio : AddToPortfolioRoutes + @Serializable data object TokenActions : AddToPortfolioRoutes } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt index e0f328c406..7eb7d23e9c 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt @@ -26,6 +26,7 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( override val onDismiss: Channel = Channel() override val onSuccessAdded: Channel = Channel() + override val onAddedTokenClick: Channel = Channel() override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), @@ -63,6 +64,10 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( onSuccessAdded.trySend(result) } + override fun onAddedTokenClick(result: AddToPortfolioManager.Result) { + onAddedTokenClick.trySend(result) + } + override fun setTokenNetworks(networks: List) { updateInternal(networks = networks) } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt new file mode 100644 index 0000000000..d09e0cfcaf --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt @@ -0,0 +1,47 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.markets.tokenselector.StickyFooter +import com.tangem.common.ui.markets.tokenselector.TokenSelectorEmbeddedContent +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultUserPortfolioComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted params: UserPortfolioComponent.Params, +) : UserPortfolioComponent, AppComponentContext by context { + + private val model: UserPortfolioModel = getOrCreateModel(params) + private val onContinueClick: () -> Unit = params.callbacks::onContinueFromUserPortfolio + + @Composable + override fun Content(modifier: Modifier) { + val stateFlow = model.state.collectAsStateWithLifecycle() + val state = stateFlow.value ?: return + TokenSelectorEmbeddedContent( + content = state.content, + modifier = modifier, + stickyFooter = StickyFooter( + buttonText = resourceReference(R.string.common_add), + isEnabled = state.isAddEnabled, + onClick = onContinueClick, + ), + ) + } + + @AssistedFactory + interface Factory : UserPortfolioComponent.Factory { + override fun create( + context: AppComponentContext, + params: UserPortfolioComponent.Params, + ): DefaultUserPortfolioComponent + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/UserPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/UserPortfolioComponent.kt new file mode 100644 index 0000000000..b827f3568d --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/UserPortfolioComponent.kt @@ -0,0 +1,20 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM +import kotlinx.coroutines.flow.StateFlow + +internal interface UserPortfolioComponent : ComposableContentComponent { + + data class Params( + val uiState: StateFlow, + val callbacks: Callbacks, + ) + + interface Callbacks { + fun onContinueFromUserPortfolio() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioModel.kt new file mode 100644 index 0000000000..9bd839e9b9 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioModel.kt @@ -0,0 +1,20 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@ModelScoped +internal class UserPortfolioModel @Inject constructor( + override val dispatchers: CoroutineDispatcherProvider, + paramsContainer: ParamsContainer, +) : Model() { + + private val params = paramsContainer.require() + + val state: StateFlow = params.uiState +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioUM.kt new file mode 100644 index 0000000000..bece060837 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/model/UserPortfolioUM.kt @@ -0,0 +1,10 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model + +import androidx.compose.runtime.Immutable +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM + +@Immutable +internal data class UserPortfolioUM( + val content: TokenSelectorContentUM, + val isAddEnabled: Boolean, +) \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/state/UserPortfolioStateController.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/state/UserPortfolioStateController.kt new file mode 100644 index 0000000000..2feeec1c92 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/state/UserPortfolioStateController.kt @@ -0,0 +1,57 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.state + +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer.UserPortfolioSectionsTransformer +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.* + +internal class UserPortfolioStateController @AssistedInject constructor( + getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + @Assisted private val modelScope: CoroutineScope, + @Assisted private val onTokenSelected: (AddToPortfolioManager.Result) -> Unit, +) { + + private val requiredDataFlow = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val uiState: StateFlow = combine( + flow = requiredDataFlow.distinctUntilChanged(), + flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(), + flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(), + ) { (allAvailableData, rawCurrencyId), appCurrency, isBalanceHidden -> + UserPortfolioSectionsTransformer( + availableData = allAvailableData, + rawCurrencyId = rawCurrencyId, + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + onTokenSelected = onTokenSelected, + ).transform() + } + .distinctUntilChanged() + .stateIn(modelScope, SharingStarted.Lazily, null) + + suspend fun updateAndWaitNotNullState(allAvailableData: AvailableToAddData, rawCurrencyId: CryptoCurrency.RawID) { + requiredDataFlow.tryEmit(allAvailableData to rawCurrencyId) + uiState.filterNotNull().firstOrNull() + } + + @AssistedFactory + interface Factory { + fun create( + modelScope: CoroutineScope, + onTokenSelected: (AddToPortfolioManager.Result) -> Unit, + ): UserPortfolioStateController + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/transformer/UserPortfolioSectionsTransformer.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/transformer/UserPortfolioSectionsTransformer.kt new file mode 100644 index 0000000000..1258e4fad7 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/transformer/UserPortfolioSectionsTransformer.kt @@ -0,0 +1,187 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.transformer + +import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.markets.tokenselector.* +import com.tangem.core.ui.components.marketprice.PriceChangeState +import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.percent +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM +import com.tangem.utils.StringsSigns +import com.tangem.utils.extensions.orZero +import kotlinx.collections.immutable.toImmutableList +import java.math.BigDecimal + +internal class UserPortfolioSectionsTransformer( + private val availableData: AvailableToAddData, + private val rawCurrencyId: CryptoCurrency.RawID, + private val appCurrency: AppCurrency, + private val isBalanceHidden: Boolean, + private val onTokenSelected: (AddToPortfolioManager.Result) -> Unit, +) { + + private val iconConverter = CryptoCurrencyToIconStateConverter() + + fun transform(): UserPortfolioUM { + return UserPortfolioUM( + content = TokenSelectorContentUM( + sections = buildSections(entries = generateEntries(availableData)).toImmutableList(), + ), + isAddEnabled = availableData.isAvailableToAdd, + ) + } + + private fun generateEntries(data: AvailableToAddData): List { + return data.availableToAddWallets.values.flatMap { wallet -> + wallet.accounts.filterCryptoPortfolio().flatMap { accountStatus -> + accountStatus.tokenList.flattenCurrencies() + .filter { status -> status.currency.matchesRawId(rawCurrencyId) } + .map { status -> + PortfolioEntry( + wallet = wallet.userWallet, + account = accountStatus, + currencyStatus = status, + ) + } + } + } + } + + private fun buildSections(entries: List): List { + val sections = mutableListOf() + val byWallet = entries.groupBy { it.wallet.walletId } + val shouldShowWalletHeaders = byWallet.size > 1 + + for ((_, walletEntries) in byWallet) { + if (shouldShowWalletHeaders) { + sections.add( + TokenSelectorSectionUM.WalletHeader(walletName = walletEntries.first().wallet.name), + ) + } + + val byAccount = walletEntries.groupBy { it.account.account.accountId } + val shouldShowAccountHeaders = byAccount.size > 1 + + for ((_, accountEntries) in byAccount) { + val singles = accountEntries.map(::entryToSingle).toImmutableList() + val accountHeader = if (shouldShowAccountHeaders) { + val first = accountEntries.first() + AccountHeaderData( + accountName = first + .account + .account + .accountName + .toUM() + .value, + cryptoPortfolioIcon = first.account.account.icon, + ) + } else { + null + } + sections.add( + TokenSelectorSectionUM.TokenGroup(accountHeader = accountHeader, items = singles), + ) + } + } + return sections + } + + private fun entryToSingle(entry: PortfolioEntry): UserAssetItemUM.Single { + val currency = entry.currencyStatus.currency + val value = entry.currencyStatus.value + return UserAssetItemUM.Single( + id = "${entry.wallet.walletId.stringValue}_${entry.account.account.accountId.value}_${currency.id.value}", + icon = TangemIconUM.Currency(currencyIconState = iconConverter.convert(entry.currencyStatus)), + tokenName = currency.name, + tokenSymbol = currency.symbol, + fiatRate = value.fiatRate?.format { fiat(appCurrency.code, appCurrency.symbol) }, + priceChangeState = when (value) { + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.NoAmount, + -> PriceChangeState.Unknown + else -> PriceChangeState.Content( + type = PriceChangeType.fromBigDecimal(value.priceChange.orZero()), + valueInPercent = value.priceChange.format { percent() }, + ) + }, + balanceState = convertBalanceState(value, currency.symbol, currency.decimals), + isBalanceHidden = isBalanceHidden, + onClick = { + onTokenSelected( + AddToPortfolioManager.Result( + wallet = entry.wallet, + account = entry.account, + addedCurrency = entry.currencyStatus, + ), + ) + }, + networkName = currency.network.name, + ) + } + + private data class PortfolioEntry( + val wallet: UserWallet, + val account: AccountStatus.CryptoPortfolio, + val currencyStatus: CryptoCurrencyStatus, + ) + + private fun convertBalanceState( + value: CryptoCurrencyStatus.Value, + symbol: String, + decimals: Int, + ): BalanceDisplayState { + return when { + value is CryptoCurrencyStatus.Loading && value.amount != null -> + BalanceDisplayState.Flickering( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + value is CryptoCurrencyStatus.Loading -> BalanceDisplayState.Loading + value is CryptoCurrencyStatus.Unreachable -> BalanceDisplayState.Unreachable + value.isError && value.amount != null -> + BalanceDisplayState.Stale( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + value.isError -> BalanceDisplayState.Unreachable + else -> BalanceDisplayState.Loaded( + cryptoBalance = stringReference(formatCryptoAmount(value.amount, symbol, decimals)), + fiatBalance = stringReference( + value.fiatAmount?.format { fiat(appCurrency.code, appCurrency.symbol) } + ?: StringsSigns.DASH_SIGN, + ), + ) + } + } + + private fun formatCryptoAmount(amount: BigDecimal?, symbol: String, decimals: Int): String { + return amount?.format { crypto(symbol, decimals) } ?: StringsSigns.DASH_SIGN + } + + private fun CryptoCurrency.matchesRawId(target: CryptoCurrency.RawID): Boolean { + val rawId = id.rawCurrencyId ?: return false + return getTokenIdIfL2Network(rawId.value) == target.value + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt new file mode 100644 index 0000000000..34c14e1224 --- /dev/null +++ b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt @@ -0,0 +1,591 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.model + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth +import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.TokenMarketInfo +import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.account.DerivationIndex +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddAccount +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddData +import com.tangem.features.commonfeatures.api.addtoportfolio.AvailableToAddWallet +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AddToPortfolioInitialSelectionResolverTest { + + private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency = mockk() + private val networkHasDerivationUseCase: NetworkHasDerivationUseCase = mockk() + + private val tokenParams: TokenMarketParams = mockk() + + private lateinit var resolver: AddToPortfolioInitialSelectionResolver + + @BeforeEach + fun setup() { + clearMocks(getTokenMarketCryptoCurrency, networkHasDerivationUseCase) + coEvery { getTokenMarketCryptoCurrency(any(), any(), any(), any()) } returns null + every { networkHasDerivationUseCase(any(), any()) } returns false.right() + + resolver = AddToPortfolioInitialSelectionResolver( + getTokenMarketCryptoCurrency = getTokenMarketCryptoCurrency, + networkHasDerivationUseCase = networkHasDerivationUseCase, + ) + } + + @Test + fun `GIVEN no wallets in data WHEN resolve THEN return null`() = runTest { + val data = availableData(wallets = emptyMap()) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = null, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNull() + } + + @Test + fun `GIVEN accountToAdd is provided WHEN resolve THEN use it instead of looking up account`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val mainAccount = availableAccount() + val explicitAccount = availableAccount(availableToAddNetworks = setOf(BITCOIN)) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to mainAccount), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(explicitAccount) + Truth.assertThat(result.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN accountToAdd is not available to add WHEN resolve THEN still use it`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(isAvailableToAdd = false, availableToAddNetworks = setOf(ETHEREUM)) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(explicitAccount) + } + + @Test + fun `GIVEN accountToAdd with no matching ordered networks WHEN resolve THEN fall back to first ordered network`() = + runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(availableToAddNetworks = setOf(ETHEREUM)) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(explicitAccount) + Truth.assertThat(result.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN selected wallet is in data WHEN resolve THEN pick its entry`() = runTest { + val selectedWalletId = UserWalletId(WALLET_ID_A) + val otherWalletId = UserWalletId(WALLET_ID_B) + val selectedUserWallet = userWallet(selectedWalletId) + val otherUserWallet = userWallet(otherWalletId) + + val selectedAccount = availableAccount() + val otherAccount = availableAccount() + + val data = availableData( + wallets = linkedMapOf( + otherWalletId to walletEntry(otherUserWallet, mapOf(AccountId.forMainCryptoPortfolio(otherWalletId) to otherAccount)), + selectedWalletId to walletEntry(selectedUserWallet, mapOf(AccountId.forMainCryptoPortfolio(selectedWalletId) to selectedAccount)), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = selectedUserWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(selectedUserWallet) + Truth.assertThat(result.account).isSameInstanceAs(selectedAccount) + } + + @Test + fun `GIVEN selected wallet is not in data WHEN resolve THEN fall back to first wallet`() = runTest { + val firstWalletId = UserWalletId(WALLET_ID_A) + val firstUserWallet = userWallet(firstWalletId) + val firstAccount = availableAccount() + + val data = availableData( + wallets = mapOf( + firstWalletId to walletEntry(firstUserWallet, mapOf(AccountId.forMainCryptoPortfolio(firstWalletId) to firstAccount)), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet(UserWalletId(WALLET_ID_B)), + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(firstUserWallet) + } + + @Test + fun `GIVEN selected wallet is null WHEN resolve THEN fall back to first wallet`() = runTest { + val firstWalletId = UserWalletId(WALLET_ID_A) + val firstUserWallet = userWallet(firstWalletId) + val firstAccount = availableAccount() + + val data = availableData( + wallets = mapOf( + firstWalletId to walletEntry(firstUserWallet, mapOf(AccountId.forMainCryptoPortfolio(firstWalletId) to firstAccount)), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = null, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(firstUserWallet) + } + + @Test + fun `GIVEN main account is available WHEN resolve THEN pick main account`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val mainAccount = availableAccount() + val otherAccount = availableAccount() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = linkedMapOf( + AccountId.forPaymentAccount(walletId) to otherAccount, + AccountId.forMainCryptoPortfolio(walletId) to mainAccount, + ), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(mainAccount) + } + + @Test + fun `GIVEN main account is not available WHEN resolve THEN pick first available account`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val mainAccount = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + val secondAvailable = availableAccount() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = linkedMapOf( + AccountId.forMainCryptoPortfolio(walletId) to mainAccount, + AccountId.forPaymentAccount(walletId) to secondAvailable, + ), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.account).isSameInstanceAs(secondAvailable) + } + + @Test + fun `GIVEN no available accounts WHEN resolve THEN fall back to that account with first ordered network`() = + runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val unavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to unavailable), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(userWallet) + Truth.assertThat(result.account).isSameInstanceAs(unavailable) + Truth.assertThat(result.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN ordered networks do not match account networks WHEN resolve THEN fall back to first ordered network`() = + runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM)) + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(userWallet) + Truth.assertThat(result.account).isSameInstanceAs(account) + Truth.assertThat(result.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN preferred wallet has no available accounts but another wallet does WHEN resolve THEN pick the other`() = + runTest { + val preferredWalletId = UserWalletId(WALLET_ID_A) + val otherWalletId = UserWalletId(WALLET_ID_B) + val preferredUserWallet = userWallet(preferredWalletId) + val otherUserWallet = userWallet(otherWalletId) + + val preferredUnavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + val otherAvailable = availableAccount(availableToAddNetworks = setOf(ETHEREUM)) + + val data = availableData( + wallets = linkedMapOf( + preferredWalletId to walletEntry( + userWallet = preferredUserWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(preferredWalletId) to preferredUnavailable), + ), + otherWalletId to walletEntry( + userWallet = otherUserWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(otherWalletId) to otherAvailable), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = preferredUserWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(otherUserWallet) + Truth.assertThat(result.account).isSameInstanceAs(otherAvailable) + Truth.assertThat(result.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN no wallet has viable combo WHEN resolve THEN fall back to preferred wallet with first ordered network`() = + runTest { + val preferredWalletId = UserWalletId(WALLET_ID_A) + val otherWalletId = UserWalletId(WALLET_ID_B) + val preferredUserWallet = userWallet(preferredWalletId) + val otherUserWallet = userWallet(otherWalletId) + + val preferredUnavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + val otherUnavailable = availableAccount(isAvailableToAdd = false, availableToAddNetworks = emptySet()) + + val data = availableData( + wallets = linkedMapOf( + otherWalletId to walletEntry( + userWallet = otherUserWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(otherWalletId) to otherUnavailable), + ), + preferredWalletId to walletEntry( + userWallet = preferredUserWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(preferredWalletId) to preferredUnavailable), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM), + selectedWallet = preferredUserWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result!!.userWallet).isSameInstanceAs(preferredUserWallet) + Truth.assertThat(result.account).isSameInstanceAs(preferredUnavailable) + Truth.assertThat(result.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN first ordered network has derivation WHEN resolve THEN pick first`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns true.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM, BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result?.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN only second ordered network has derivation WHEN resolve THEN pick second`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns false.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM, BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result?.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN no network has derivation WHEN resolve THEN fall back to first ordered available network`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), any()) } returns Throwable().left() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN, ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result?.network).isEqualTo(BITCOIN) + } + + @Test + fun `GIVEN get token market crypto currency returns null WHEN resolve THEN fall back to first ordered available network`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val account = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + coEvery { getTokenMarketCryptoCurrency(any(), any(), any(), any()) } returns null + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to account), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN, ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + ) + + Truth.assertThat(result?.network).isEqualTo(BITCOIN) + } + + // region Helpers + + private fun availableData(wallets: Map): AvailableToAddData = mockk { + every { isAvailableToAdd } returns true + every { availableToAddWallets } returns wallets + } + + private fun walletEntry( + userWallet: UserWallet, + accounts: Map, + ): AvailableToAddWallet = mockk { + every { this@mockk.userWallet } returns userWallet + every { availableToAddAccounts } returns accounts + } + + private fun availableAccount( + isAvailableToAdd: Boolean = true, + availableToAddNetworks: Set = setOf(ETHEREUM), + derivationIndex: DerivationIndex = DerivationIndex.Main, + ): AvailableToAddAccount = mockk { + every { this@mockk.isAvailableToAdd } returns isAvailableToAdd + every { this@mockk.availableToAddNetworks } returns availableToAddNetworks + every { account.account.derivationIndex } returns derivationIndex + } + + private fun userWallet(walletId: UserWalletId): UserWallet = mockk { + every { this@mockk.walletId } returns walletId + } + + private fun cryptoCurrency(): CryptoCurrency = mockk { + every { network } returns mockk() + } + + // endregion + + private companion object { + const val WALLET_ID_A = "011f" + const val WALLET_ID_B = "022e" + + val ETHEREUM = TokenMarketInfo.Network( + networkId = "ethereum", + isExchangeable = true, + contractAddress = null, + decimalCount = 18, + ) + + val BITCOIN = TokenMarketInfo.Network( + networkId = "bitcoin", + isExchangeable = true, + contractAddress = null, + decimalCount = 8, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index e8f7af83ef..cb3ad4c829 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -11,6 +11,7 @@ import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent @@ -29,6 +30,7 @@ internal class FeedEntryChildFactory @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val portfolioComponentFactory: MarketsPortfolioComponent.Factory, private val portfolioBlockComponentFactory: PortfolioBlockComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles, @@ -84,6 +86,7 @@ internal class FeedEntryChildFactory @Inject constructor( portfolioComponentFactory = portfolioComponentFactory, portfolioBlockComponentFactory = portfolioBlockComponentFactory, designFeatureToggles = designFeatureToggles, + addToPortfolioComponentFactory = addToPortfolioComponentFactory, ) } is Child.TokenList -> { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/AddToPortfolioSlotRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/AddToPortfolioSlotRoute.kt new file mode 100644 index 0000000000..fc1cfda981 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/AddToPortfolioSlotRoute.kt @@ -0,0 +1,7 @@ +package com.tangem.features.feed.components.market.details + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal data object AddToPortfolioSlotRoute : Route \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index c4ac5551aa..0e521d801a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -16,16 +16,20 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network -import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarType @@ -34,10 +38,13 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent +import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.state.TokenNetworksState @@ -48,13 +55,15 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +@Suppress("LongParameterList") internal class DefaultMarketsTokenDetailsComponent( appComponentContext: AppComponentContext, - val params: Params, analyticsEventHandler: AnalyticsEventHandler, designFeatureToggles: DesignFeatureToggles, portfolioComponentFactory: MarketsPortfolioComponent.Factory, portfolioBlockComponentFactory: PortfolioBlockComponent.Factory, + val params: Params, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { // applying l2 compatibility @@ -83,12 +92,28 @@ internal class DefaultMarketsTokenDetailsComponent( if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) { portfolioBlockComponentFactory.create( context = child("portfolio_block"), - params = PortfolioBlockComponent.Params(updatedParams.token), + params = PortfolioBlockComponent.Params(token = updatedParams.token), + parentRouter = object : PortfolioBlockParentClickIntents { + override fun openAddToPortfolioDirect() { + model.openAddToPortfolio() + } + + override fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) { + model.openAddToPortfolioViaUserPortfolio(rawCurrencyId) + } + }, ) } else { null } + private val addToPortfolioSlot = childSlot( + source = model.addToPortfolioSheetNavigation, + serializer = AddToPortfolioSlotRoute.serializer(), + handleBackButton = false, + childFactory = ::addToPortfolioChild, + ) + init { componentScope.launch(dispatchers.default) { model.networksState.collectLatest { state -> @@ -120,6 +145,17 @@ internal class DefaultMarketsTokenDetailsComponent( } } + private fun addToPortfolioChild( + @Suppress("UNUSED_PARAMETER") config: AddToPortfolioSlotRoute, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + val manager = model.addToPortfolioManagerOrNull() ?: return ComposableBottomSheetComponent.EMPTY + return addToPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = AddToPortfolioComponent.Params(addToPortfolioManager = manager), + ) + } + @Composable override fun Title(bottomSheetState: State) { val state by model.state.collectAsStateWithLifecycle() @@ -198,6 +234,7 @@ internal class DefaultMarketsTokenDetailsComponent( } } val state by model.state.collectAsStateWithLifecycle() + val bottomSheet by addToPortfolioSlot.subscribeAsState() val bsState by bottomSheetState LaunchedEffect(bsState) { model.isVisibleOnScreen.value = bsState == BottomSheetState.EXPANDED @@ -219,6 +256,7 @@ internal class DefaultMarketsTokenDetailsComponent( } }, ) + bottomSheet.child?.instance?.BottomSheet() } @Serializable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt index 22f52fccc7..b9d274b71b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockComponent.kt @@ -5,24 +5,13 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.slot.childSlot -import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockModel -import com.tangem.features.feed.components.market.details.portfolioblock.model.PortfolioBlockRoute import com.tangem.features.feed.components.market.details.portfolioblock.ui.PortfolioBlock -import com.tangem.features.feed.components.portfolio.PortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -32,26 +21,14 @@ import kotlinx.serialization.Serializable internal class PortfolioBlockComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @Assisted private val params: Params, + @Assisted private val parentRouter: PortfolioBlockParentClickIntents?, ) : ComposableContentComponent, AppComponentContext by context { - private val portfolioComponentFactory: PortfolioComponent.Factory = object : PortfolioComponent.Factory { - override fun create(context: AppComponentContext, params: PortfolioComponent.Params): PortfolioComponent { - TODO("STUB. Will be implemented") - } - } - - @Serializable - data class Params( - val token: TokenMarketParams, - ) - - private val model: PortfolioBlockModel = getOrCreateModel(params) - - private val bottomSheetSlot = childSlot( - source = model.bottomSheetNavigation, - serializer = PortfolioBlockRoute.serializer(), - handleBackButton = true, - childFactory = ::createBottomSheetChild, + private val model: PortfolioBlockModel = getOrCreateModel( + PortfolioBlockModelParams( + token = params.token, + parentRouter = parentRouter, + ), ) fun setTokenNetworks(networks: List) { @@ -65,51 +42,23 @@ internal class PortfolioBlockComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() - val bottomSheet by bottomSheetSlot.subscribeAsState() - PortfolioBlock(modifier = modifier, state = state) - bottomSheet.child?.instance?.BottomSheet() } - @Suppress("UnusedParameter") - private fun createBottomSheetChild( - config: PortfolioBlockRoute, - componentContext: ComponentContext, - ): ComposableBottomSheetComponent { - val currencyId = model.cryptoCurrencyIdState.value ?: return ComposableBottomSheetComponent.EMPTY - val portfolioComponent = portfolioComponentFactory.create( - context = childByContext(componentContext), - params = PortfolioComponent.Params(id = currencyId), - ) - return PortfolioBottomSheetWrapper( - portfolioComponent = portfolioComponent, - onDismiss = { model.bottomSheetNavigation.dismiss() }, - ) - } - - private class PortfolioBottomSheetWrapper( - private val portfolioComponent: PortfolioComponent, - private val onDismiss: () -> Unit, - ) : ComposableBottomSheetComponent { - - override fun dismiss() = onDismiss() - - @Composable - override fun BottomSheet() { - TangemBottomSheet( - config = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = ::dismiss, - content = TangemBottomSheetConfigContent.Empty, - ), - ) { - portfolioComponent.Content(modifier = Modifier) - } - } - } + @Serializable + data class Params(val token: TokenMarketParams) @AssistedFactory interface Factory { - fun create(context: AppComponentContext, params: Params): PortfolioBlockComponent + fun create( + context: AppComponentContext, + params: Params, + parentRouter: PortfolioBlockParentClickIntents?, + ): PortfolioBlockComponent } + + data class PortfolioBlockModelParams( + val token: TokenMarketParams, + val parentRouter: PortfolioBlockParentClickIntents?, + ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockParentClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockParentClickIntents.kt new file mode 100644 index 0000000000..8be21c2648 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/PortfolioBlockParentClickIntents.kt @@ -0,0 +1,8 @@ +package com.tangem.features.feed.components.market.details.portfolioblock + +import com.tangem.domain.models.currency.CryptoCurrency + +internal interface PortfolioBlockParentClickIntents { + fun openAddToPortfolioDirect() + fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt index 48901a9e3c..873efb8c79 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockModel.kt @@ -2,8 +2,6 @@ package com.tangem.features.feed.components.market.details.portfolioblock.model import androidx.compose.runtime.Stable import androidx.compose.ui.text.SpanStyle -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.arkivanov.decompose.router.slot.activate import com.tangem.blockchainsdk.compatibility.getTokenIdIfL2Network import com.tangem.common.getTotalFiatAmount import com.tangem.core.decompose.di.ModelScoped @@ -26,6 +24,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent +import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents import com.tangem.features.feed.components.market.details.portfolioblock.ui.state.PortfolioBlockUM import com.tangem.features.feed.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -50,13 +49,12 @@ internal class PortfolioBlockModel @Inject constructor( val state: StateFlow field = MutableStateFlow(PortfolioBlockUM.Loading) - val bottomSheetNavigation: SlotNavigation = SlotNavigation() - val cryptoCurrencyIdState: StateFlow field = MutableStateFlow(null) - private val params = paramsContainer.require() + private val params = paramsContainer.require() private val currencyRawId: CryptoCurrency.RawID = params.token.id + private val parentRouter: PortfolioBlockParentClickIntents? = params.parentRouter private val tokenIcon: CurrencyIconState = CurrencyIconState.CoinIcon( url = params.token.imageUrl, @@ -114,15 +112,12 @@ internal class PortfolioBlockModel @Inject constructor( } }.distinctUntilChanged() - val settingsFlow = combine( + return combine( + portfolioDataFlow, getSelectedAppCurrencyUseCase.invokeOrDefault(), getBalanceHidingSettingsUseCase.isBalanceHidden(), - ) { appCurrency, isBalanceHidden -> - SettingsBox(appCurrency, isBalanceHidden) - }.distinctUntilChanged() - - return combine(portfolioDataFlow, settingsFlow) { portfolios, settings -> - buildState(portfolios, settings) + ) { portfolios, appCurrency, isBalanceHidden -> + buildState(portfolios, appCurrency, isBalanceHidden) }.distinctUntilChanged() } @@ -135,7 +130,11 @@ internal class PortfolioBlockModel @Inject constructor( } } - private fun buildState(portfolios: List, settings: SettingsBox): PortfolioBlockUM { + private fun buildState( + portfolios: List, + appCurrency: AppCurrency, + isBalanceHidden: Boolean, + ): PortfolioBlockUM { val allCurrencies = portfolios.flatMap { it.currencies } val hasMultiCurrencyWallet = portfolios.any { it.userWallet.isMultiCurrency } @@ -143,36 +142,36 @@ internal class PortfolioBlockModel @Inject constructor( return if (hasMultiCurrencyWallet) { PortfolioBlockUM.AddToken( tokenIcon = tokenIcon, - onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) }, + onAddClick = { parentRouter?.openAddToPortfolioDirect() }, ) } else { PortfolioBlockUM.Hidden } } - cryptoCurrencyIdState.update { - it ?: allCurrencies.first().currency.id - } + cryptoCurrencyIdState.update { it ?: allCurrencies.first().currency.id } val totalFiat = allCurrencies.mapNotNull { it.getTotalFiatAmount() } .fold(BigDecimal.ZERO, BigDecimal::add) val formattedBalance = totalFiat.formatStyled { fiat( - fiatCurrencyCode = settings.appCurrency.code, - fiatCurrencySymbol = settings.appCurrency.symbol, + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, ) } + val firstCurrency = allCurrencies.first().currency return PortfolioBlockUM.Content( totalBalance = formattedBalance, tokensInPortfolioCount = allCurrencies.size, tokenIcon = tokenIcon, - tokenName = allCurrencies.first().currency.name, - tokenSymbol = allCurrencies.first().currency.symbol, - isBalanceHidden = settings.isBalanceHidden, - onClick = { bottomSheetNavigation.activate(PortfolioBlockRoute) }, + tokenName = firstCurrency.name, + tokenSymbol = firstCurrency.symbol, + isBalanceHidden = isBalanceHidden, + onRowClick = { parentRouter?.openAddToPortfolioViaUserPortfolio(currencyRawId) }, + onAddFundsClick = {}, ) } } @@ -180,9 +179,4 @@ internal class PortfolioBlockModel @Inject constructor( private data class WalletPortfolio( val userWallet: UserWallet, val currencies: List, -) - -private data class SettingsBox( - val appCurrency: AppCurrency, - val isBalanceHidden: Boolean, ) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt deleted file mode 100644 index 4c1f19107b..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/model/PortfolioBlockRoute.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.features.feed.components.market.details.portfolioblock.model - -import com.tangem.core.decompose.navigation.Route -import kotlinx.serialization.Serializable - -@Serializable -internal data object PortfolioBlockRoute : Route \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt index 06cc01d383..e372da6cda 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt @@ -22,19 +22,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.BottomFade import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.ds.row.TangemRowContainer -import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme @@ -95,57 +91,53 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi @Composable private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = Modifier) { - FloatingCard( - modifier = modifier, - onClick = state.onClick, - ) { - TangemRowContainer( - contentPadding = PaddingValues(0.dp), + FloatingCard(modifier = modifier) { // TODO will be handle in [REDACTED_TASK_KEY] + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - CurrencyIcon( + Row( modifier = Modifier - .padding(end = TangemTheme.dimens2.x3) - .layoutId(TangemRowLayoutId.HEAD), - state = state.tokenIcon, - ) - - Text( - modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), - text = state.tokenName, - style = TangemTheme.typography2.bodyMedium16, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - ) - - Text( - modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), - text = stringResourceSafe(R.string.markets_portfolio_block_subtitle), - style = TangemTheme.typography2.captionMedium12, - color = TangemTheme.colors2.text.neutral.secondary, - maxLines = 1, - ) - - Text( - modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), - text = state.totalBalance.orMaskWithStars(state.isBalanceHidden).resolveAnnotatedReference(), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = TangemTheme.colors2.text.neutral.primary, - style = TangemTheme.typography2.bodyMedium16, - ) - - Text( - modifier = Modifier.layoutId(TangemRowLayoutId.END_BOTTOM), - text = state.tokenSymbol, - style = TangemTheme.typography2.captionMedium12, - color = TangemTheme.colors2.text.neutral.secondary, - maxLines = 1, - ) - + .weight(1f) + .clickable(onClick = state.onRowClick), + verticalAlignment = Alignment.CenterVertically, + ) { + CurrencyIcon( + modifier = Modifier.padding(end = TangemTheme.dimens2.x3), + state = state.tokenIcon, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.tokenName, + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + ) + Text( + text = stringResourceSafe(R.string.markets_portfolio_block_subtitle), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = state.totalBalance.orMaskWithStars(state.isBalanceHidden).resolveAnnotatedReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.bodyMedium16, + ) + Text( + text = state.tokenSymbol, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + } + } TangemButton( - modifier = Modifier - .padding(start = TangemTheme.dimens2.x3) - .layoutId(TangemRowLayoutId.TAIL), + modifier = Modifier.padding(start = TangemTheme.dimens2.x3), buttonUM = TangemButtonUM( type = TangemButtonType.Secondary, tangemIconUM = TangemIconUM.Icon( @@ -154,7 +146,7 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M ), shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - onClick = state.onClick, + onClick = state.onAddFundsClick, ), ) } @@ -163,10 +155,7 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M @Composable private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = Modifier) { - FloatingCard( - modifier = modifier, - onClick = state.onClick, - ) { + FloatingCard(modifier = modifier) { Row(verticalAlignment = Alignment.CenterVertically) { CurrencyIcon(state.tokenIcon) @@ -191,7 +180,7 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = text = resourceReference(R.string.common_add), shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - onClick = state.onClick, + onClick = state.onAddClick, ), ) } @@ -200,7 +189,7 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun FloatingCard(modifier: Modifier = Modifier, onClick: () -> Unit = {}, content: @Composable () -> Unit) { +private fun FloatingCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) { Box( modifier = modifier .navigationBarsPadding() @@ -216,7 +205,6 @@ private fun FloatingCard(modifier: Modifier = Modifier, onClick: () -> Unit = {} color = TangemTheme.colors2.surface.level3, shape = RoundedCornerShape(size = TangemTheme.dimens2.x5), ) - .clickable(onClick = onClick) .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3), ) { content() @@ -236,7 +224,8 @@ private fun ContentPreview() { tokenName = "Bitcoin", tokenSymbol = "BTC", isBalanceHidden = false, - onClick = {}, + onRowClick = {}, + onAddFundsClick = {}, ), ) } @@ -250,7 +239,7 @@ private fun AddTokenPreview() { PortfolioBlock( state = PortfolioBlockUM.AddToken( tokenIcon = previewCoinIcon, - onClick = {}, + onAddClick = {}, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt index f61b90aa05..136a952189 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/state/PortfolioBlockUM.kt @@ -12,7 +12,7 @@ internal sealed class PortfolioBlockUM { data class AddToken( val tokenIcon: CurrencyIconState, - val onClick: () -> Unit, + val onAddClick: () -> Unit, ) : PortfolioBlockUM() data class Content( @@ -22,6 +22,7 @@ internal sealed class PortfolioBlockUM { val tokenName: String, val tokenSymbol: String, val isBalanceHidden: Boolean, - val onClick: () -> Unit, + val onRowClick: () -> Unit, + val onAddFundsClick: () -> Unit, ) : PortfolioBlockUM() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt index 7ac509eccf..b595fbe70c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchBottomSheetRoute.kt @@ -1,15 +1,15 @@ package com.tangem.features.feed.components.search import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.models.portfolio.UserAssetEntry internal sealed interface SearchBottomSheetRoute { data class TokenSelector( - val entries: List, + val entries: List, val appCurrency: AppCurrency, val isBalanceHidden: Boolean, - val onTokenSelected: (UserAssetSearchEntry) -> Unit, + val onTokenSelected: (UserAssetEntry) -> Unit, val onDismiss: () -> Unit, ) : SearchBottomSheetRoute } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt index a36895267e..6fe668dc82 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/search/SearchTokenSelectorComponent.kt @@ -7,9 +7,9 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.common.ui.markets.tokenselector.TokenSelectorBottomSheet import com.tangem.features.feed.model.search.SearchTokenSelectorModel -import com.tangem.features.feed.ui.search.components.TokenSelectorBottomSheet import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -37,10 +37,10 @@ internal class SearchTokenSelectorComponent @AssistedInject constructor( } data class Params( - val entries: List, + val entries: List, val appCurrency: AppCurrency, val isBalanceHidden: Boolean, - val onTokenSelected: (UserAssetSearchEntry) -> Unit, + val onTokenSelected: (UserAssetEntry) -> Unit, val onDismiss: () -> Unit, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index 024e7ff952..2916584e41 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -3,9 +3,13 @@ package com.tangem.features.feed.model.market.details import androidx.compose.runtime.Stable import arrow.core.Either import arrow.core.getOrElse +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.common.TangemSiteShareUrlBuilder -import com.tangem.domain.markets.PreselectedTokenDetailsSection +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.MarketChartDataProducer import com.tangem.common.ui.charts.state.sorted @@ -36,6 +40,7 @@ import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.markets.* +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.usecase.GetNewsUseCase @@ -43,6 +48,8 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.feed.components.market.details.AddToPortfolioSlotRoute import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.details.analytics.MarketTokenAnalyticsEvent import com.tangem.features.feed.impl.R @@ -65,6 +72,7 @@ import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -81,6 +89,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, getUserCountryUseCase: GetUserCountryUseCase, paramsContainer: ParamsContainer, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, private val designFeatureToggles: DesignFeatureToggles, private val getTokenPriceChartUseCase: GetTokenPriceChartUseCase, private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase, @@ -93,6 +102,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val urlOpener: UrlOpener, private val getNewsUseCase: GetNewsUseCase, private val shareManager: ShareManager, + private val appRouter: AppRouter, ) : Model() { private val quotesJob = JobHolder() @@ -224,6 +234,13 @@ internal class MarketsTokenDetailsModel @Inject constructor( val isVisibleOnScreen = MutableStateFlow(false) val networksState = MutableStateFlow(TokenNetworksState.Loading) + val addToPortfolioSheetNavigation = SlotNavigation() + + private val isAddToPortfolioAvailable: Boolean = + params.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled + private var addToPortfolioManager: AddToPortfolioManager? = null + private var addToPortfolioListenersJob: Job? = null + val state = MutableStateFlow( MarketsTokenDetailsUM( tokenName = params.token.name, @@ -309,6 +326,77 @@ internal class MarketsTokenDetailsModel @Inject constructor( onListedOnClick(exchangesCount) } } + + modelScope.launch { + networksState.collect { tokenNetworkState -> + val manager = addToPortfolioManager ?: return@collect + when (tokenNetworkState) { + is TokenNetworksState.NetworksAvailable -> manager.setTokenNetworks(tokenNetworkState.networks) + TokenNetworksState.NoNetworksAvailable -> manager.setTokenNetworks(emptyList()) + else -> Unit + } + } + } + } + + fun openAddToPortfolio() { + if (!isAddToPortfolioAvailable) return + prepareAddToPortfolioManager(AddToPortfolioManager.LaunchMode.DirectAdd) + addToPortfolioSheetNavigation.activate(AddToPortfolioSlotRoute) + } + + fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) { + if (!isAddToPortfolioAvailable) return + prepareAddToPortfolioManager(AddToPortfolioManager.LaunchMode.ViaUserPortfolio(rawCurrencyId)) + addToPortfolioSheetNavigation.activate(AddToPortfolioSlotRoute) + } + + private fun openTokenDetails(result: AddToPortfolioManager.Result) { + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = result.wallet.walletId, + currency = result.addedCurrency.currency, + ), + ) + } + + fun addToPortfolioManagerOrNull(): AddToPortfolioManager? = addToPortfolioManager + + private fun prepareAddToPortfolioManager(launchMode: AddToPortfolioManager.LaunchMode) { + addToPortfolioListenersJob?.cancel() + val manager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings( + shouldSkipTokenActionsScreen = false, + launchMode = launchMode, + ), + analyticsParams = AddToPortfolioManager.AnalyticsParams(params.analyticsParams?.source), + ) + manager.setTokenParams(params.token) + when (val network = networksState.value) { + is TokenNetworksState.NetworksAvailable -> manager.setTokenNetworks(network.networks) + TokenNetworksState.NoNetworksAvailable -> manager.setTokenNetworks(emptyList()) + else -> Unit + } + addToPortfolioManager = manager + addToPortfolioListenersJob = modelScope.launch { + launch { + manager.onDismiss.receiveAsFlow().collect { + addToPortfolioSheetNavigation.dismiss() + } + } + launch { + manager.onSuccessAdded.receiveAsFlow().collect { + addToPortfolioSheetNavigation.dismiss() + } + } + launch { + manager.onAddedTokenClick.receiveAsFlow().collect { result -> + addToPortfolioSheetNavigation.dismiss() + openTokenDetails(result) + } + } + } } private fun initialLoad() { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt index 8dc79c7ec2..28d4ac7e21 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchModel.kt @@ -18,7 +18,7 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.markets.* -import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.models.portfolio.UserAssetEntry import com.tangem.domain.search.model.UserAssetSearchItem import com.tangem.domain.search.usecase.ClearSearchHistoryUseCase import com.tangem.domain.search.usecase.GetSearchResultsUseCase @@ -213,7 +213,7 @@ internal class SearchModel @Inject constructor( stateController.update(UpdateSearchBarQueryTransformer("")) } - private fun onSingleUserAssetClick(entry: UserAssetSearchEntry) { + private fun onSingleUserAssetClick(entry: UserAssetEntry) { searchAnalyticsHelper.sendPortfolioItemClicked(entry.currencyStatus.currency.symbol) appRouter.push( AppRoute.CurrencyDetails( @@ -236,7 +236,7 @@ internal class SearchModel @Inject constructor( ) } - private fun onTokenSelectedFromGroup(entry: UserAssetSearchEntry) { + private fun onTokenSelectedFromGroup(entry: UserAssetEntry) { bottomSheetNavigation.dismiss() onSingleUserAssetClick(entry) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt index 9c8a912394..7111311a4d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/SearchTokenSelectorModel.kt @@ -1,13 +1,13 @@ package com.tangem.features.feed.model.search import androidx.compose.runtime.Stable +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.features.feed.components.search.SearchTokenSelectorComponent import com.tangem.features.feed.model.search.state.TokenSelectorStateController import com.tangem.features.feed.model.search.state.transformers.BuildTokenSelectorSectionsTransformer -import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt index 9d9e043453..a57bd66a42 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/converter/UserAssetSearchItemConverter.kt @@ -1,12 +1,14 @@ package com.tangem.features.feed.model.search.converter -import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated -import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.extensions.networkIconResId +import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat @@ -14,10 +16,8 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.search.model.UserAssetSearchEntry +import com.tangem.domain.models.portfolio.UserAssetEntry import com.tangem.domain.search.model.UserAssetSearchItem -import com.tangem.features.feed.ui.search.state.BalanceDisplayState -import com.tangem.features.feed.ui.search.state.UserAssetItemUM import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.orZero @@ -26,7 +26,7 @@ import java.math.BigDecimal internal class UserAssetSearchItemConverter( private val appCurrency: AppCurrency, private val isBalanceHidden: Boolean, - private val onSingleClick: (UserAssetSearchEntry) -> Unit, + private val onSingleClick: (UserAssetEntry) -> Unit, private val onGroupedClick: (UserAssetSearchItem.Grouped) -> Unit, ) : Converter { @@ -37,7 +37,7 @@ internal class UserAssetSearchItemConverter( } } - private fun convertSingle(entry: UserAssetSearchEntry): UserAssetItemUM.Single { + private fun convertSingle(entry: UserAssetEntry): UserAssetItemUM.Single { val currency = entry.currencyStatus.currency val value = entry.currencyStatus.value @@ -124,7 +124,7 @@ internal class UserAssetSearchItemConverter( } private fun convertGroupedBalanceState( - entries: List, + entries: List, symbol: String, decimals: Int, ): BalanceDisplayState { @@ -145,7 +145,7 @@ internal class UserAssetSearchItemConverter( } private fun computeGroupBalance( - entries: List, + entries: List, symbol: String, decimals: Int, ): BalanceDisplayState.Loaded { @@ -158,7 +158,7 @@ internal class UserAssetSearchItemConverter( } private fun computeGroupBalanceFlickering( - entries: List, + entries: List, symbol: String, decimals: Int, ): BalanceDisplayState.Flickering { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt index 40effc11de..6e3c068eeb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/TokenSelectorStateController.kt @@ -1,8 +1,8 @@ package com.tangem.features.feed.model.search.state +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.features.feed.model.search.state.transformers.TokenSelectorUMTransformer -import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt index a6c2729c0e..c3d9e66e4d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformer.kt @@ -1,18 +1,18 @@ package com.tangem.features.feed.model.search.state.transformers import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.markets.tokenselector.AccountHeaderData +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM +import com.tangem.common.ui.markets.tokenselector.TokenSelectorSectionUM import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.search.model.UserAssetSearchEntry -import com.tangem.features.feed.ui.search.state.AccountHeaderData -import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM -import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM +import com.tangem.domain.models.portfolio.UserAssetEntry import kotlinx.collections.immutable.toImmutableList internal class BuildTokenSelectorSectionsTransformer( - private val entries: List, + private val entries: List, private val appCurrency: AppCurrency, private val isBalanceHidden: Boolean, - private val onTokenSelected: (UserAssetSearchEntry) -> Unit, + private val onTokenSelected: (UserAssetEntry) -> Unit, ) : TokenSelectorUMTransformer { private val entryConverter = TokenSelectorEntryConverter( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt index abc37b6790..7f6f59f367 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverter.kt @@ -1,6 +1,8 @@ package com.tangem.features.feed.model.search.state.transformers import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType import com.tangem.core.ui.ds.image.TangemIconUM @@ -11,9 +13,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.search.model.UserAssetSearchEntry -import com.tangem.features.feed.ui.search.state.BalanceDisplayState -import com.tangem.features.feed.ui.search.state.UserAssetItemUM +import com.tangem.domain.models.portfolio.UserAssetEntry import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.orZero @@ -22,12 +22,12 @@ import java.math.BigDecimal internal class TokenSelectorEntryConverter( private val appCurrency: AppCurrency, private val isBalanceHidden: Boolean, - private val onTokenSelected: (UserAssetSearchEntry) -> Unit, -) : Converter { + private val onTokenSelected: (UserAssetEntry) -> Unit, +) : Converter { private val iconConverter = CryptoCurrencyToIconStateConverter() - override fun convert(value: UserAssetSearchEntry): UserAssetItemUM.Single { + override fun convert(value: UserAssetEntry): UserAssetItemUM.Single { val currency = value.currencyStatus.currency val currencyValue = value.currencyStatus.value diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt index f364f03d07..480f20b319 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorUMTransformer.kt @@ -1,6 +1,6 @@ package com.tangem.features.feed.model.search.state.transformers -import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM internal interface TokenSelectorUMTransformer { fun transform(prevState: TokenSelectorContentUM): TokenSelectorContentUM diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt index d99fdf0e7c..91e0fccbbe 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/search/state/transformers/UpdateUserAssetsTransformer.kt @@ -1,9 +1,9 @@ package com.tangem.features.feed.model.search.state.transformers +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM import com.tangem.features.feed.ui.search.state.MarketSearchResultUM import com.tangem.features.feed.ui.search.state.SearchContentUM import com.tangem.features.feed.ui.search.state.SearchUM -import com.tangem.features.feed.ui.search.state.UserAssetItemUM import kotlinx.collections.immutable.ImmutableList internal class UpdateUserAssetsTransformer( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt index 33b2589a72..41bed10ea9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/SearchContent.kt @@ -22,6 +22,9 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.markets.MarketsListItem import com.tangem.common.ui.markets.MarketsListItemPlaceholder import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.common.ui.markets.tokenselector.GroupedUserAssetItem +import com.tangem.common.ui.markets.tokenselector.SingleUserAssetItem +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerW @@ -33,8 +36,6 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem -import com.tangem.features.feed.ui.search.components.SingleUserAssetItem import com.tangem.features.feed.ui.search.state.* import kotlinx.collections.immutable.ImmutableList diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt index 298dbfbcf1..24cc5d32c7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/SearchContentPreview.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.marketprice.PriceChangeState import com.tangem.core.ui.components.marketprice.PriceChangeType diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt index 2c9aecee9d..b2464602e1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/preview/UserAssetItemPreview.kt @@ -19,10 +19,10 @@ import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.features.feed.ui.search.components.GroupedUserAssetItem -import com.tangem.features.feed.ui.search.components.SingleUserAssetItem -import com.tangem.features.feed.ui.search.state.BalanceDisplayState -import com.tangem.features.feed.ui.search.state.UserAssetItemUM +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState +import com.tangem.common.ui.markets.tokenselector.GroupedUserAssetItem +import com.tangem.common.ui.markets.tokenselector.SingleUserAssetItem +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM /** Labeled UI state for [SingleUserAssetItem] previews (dropdown label in Studio). */ internal data class SingleUserAssetItemPreviewScenario( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt index 595a24e12b..7dbf384023 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/search/state/SearchUM.kt @@ -2,10 +2,8 @@ package com.tangem.features.feed.ui.search.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM +import com.tangem.common.ui.markets.tokenselector.UserAssetItemUM import com.tangem.core.ui.components.fields.entity.SearchBarUM -import com.tangem.core.ui.components.marketprice.PriceChangeState -import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList data class SearchUM( @@ -43,59 +41,4 @@ sealed interface MarketSearchResultUM { data object Empty : MarketSearchResultUM } -data class TextHintItemUM(val text: String) - -@Immutable -sealed interface BalanceDisplayState { - - data class Loaded( - val cryptoBalance: TextReference, - val fiatBalance: TextReference, - ) : BalanceDisplayState - - data class Flickering( - val cryptoBalance: TextReference, - val fiatBalance: TextReference, - ) : BalanceDisplayState - - data class Stale( - val cryptoBalance: TextReference, - val fiatBalance: TextReference, - ) : BalanceDisplayState - - data object Loading : BalanceDisplayState - data object Unreachable : BalanceDisplayState -} - -@Immutable -sealed interface UserAssetItemUM { - val id: String - val icon: TangemIconUM - val tokenName: String - val tokenSymbol: String - val onClick: () -> Unit - - data class Single( - override val id: String, - override val icon: TangemIconUM, - override val tokenName: String, - override val tokenSymbol: String, - val fiatRate: String?, - val priceChangeState: PriceChangeState, - val balanceState: BalanceDisplayState, - val isBalanceHidden: Boolean, - val networkName: String, - override val onClick: () -> Unit, - ) : UserAssetItemUM - - data class Grouped( - override val id: String, - override val icon: TangemIconUM, - override val tokenName: String, - override val tokenSymbol: String, - val tokensCount: Int, - val balanceState: BalanceDisplayState, - val isBalanceHidden: Boolean, - override val onClick: () -> Unit, - ) : UserAssetItemUM -} \ No newline at end of file +data class TextHintItemUM(val text: String) \ No newline at end of file diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt index 5d588ab0d3..dd22b5353c 100644 --- a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/BuildTokenSelectorSectionsTransformerTest.kt @@ -8,9 +8,9 @@ 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.wallet.UserWalletId -import com.tangem.domain.search.model.UserAssetSearchEntry -import com.tangem.features.feed.ui.search.state.TokenSelectorContentUM -import com.tangem.features.feed.ui.search.state.TokenSelectorSectionUM +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.common.ui.markets.tokenselector.TokenSelectorContentUM +import com.tangem.common.ui.markets.tokenselector.TokenSelectorSectionUM import io.mockk.* import kotlinx.collections.immutable.persistentListOf import org.junit.jupiter.api.BeforeEach @@ -22,7 +22,7 @@ import java.math.BigDecimal class BuildTokenSelectorSectionsTransformerTest { private val appCurrency: AppCurrency = AppCurrency.Default - private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true) + private val onTokenSelected: (UserAssetEntry) -> Unit = mockk(relaxed = true) private val prevState = TokenSelectorContentUM(sections = persistentListOf()) @BeforeEach @@ -231,7 +231,7 @@ class BuildTokenSelectorSectionsTransformerTest { // region Helpers - private fun createTransformer(entries: List): BuildTokenSelectorSectionsTransformer { + private fun createTransformer(entries: List): BuildTokenSelectorSectionsTransformer { return BuildTokenSelectorSectionsTransformer( entries = entries, appCurrency = appCurrency, @@ -259,7 +259,7 @@ class BuildTokenSelectorSectionsTransformerTest { currencyId: String = "btc", currencyName: String = "Bitcoin", currencySymbol: String = "BTC", - ): UserAssetSearchEntry { + ): UserAssetEntry { val network = mockk(relaxed = true) { every { name } returns "Network" } @@ -290,7 +290,7 @@ class BuildTokenSelectorSectionsTransformerTest { sources = CryptoCurrencyStatus.Sources(), ) } - return mockk { + return mockk { every { userWalletId } returns walletId every { userWalletName } returns walletName every { this@mockk.accountId } returns accountId diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt index 9663191563..579609ec26 100644 --- a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/search/state/transformers/TokenSelectorEntryConverterTest.kt @@ -8,8 +8,8 @@ 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.wallet.UserWalletId -import com.tangem.domain.search.model.UserAssetSearchEntry -import com.tangem.features.feed.ui.search.state.BalanceDisplayState +import com.tangem.domain.models.portfolio.UserAssetEntry +import com.tangem.common.ui.markets.tokenselector.BalanceDisplayState import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk @@ -23,7 +23,7 @@ import java.math.BigDecimal class TokenSelectorEntryConverterTest { private val appCurrency: AppCurrency = AppCurrency.Default - private val onTokenSelected: (UserAssetSearchEntry) -> Unit = mockk(relaxed = true) + private val onTokenSelected: (UserAssetEntry) -> Unit = mockk(relaxed = true) private lateinit var converter: TokenSelectorEntryConverter @@ -276,7 +276,7 @@ class TokenSelectorEntryConverterTest { fiatRate = BigDecimal("30000.0"), priceChange = BigDecimal("1.0"), ), - ): UserAssetSearchEntry { + ): UserAssetEntry { val userWalletId = mockk { every { stringValue } returns walletId } @@ -302,7 +302,7 @@ class TokenSelectorEntryConverterTest { every { this@mockk.currency } returns currency every { this@mockk.value } returns value } - return mockk { + return mockk { every { this@mockk.userWalletId } returns userWalletId every { this@mockk.userWalletName } returns "Wallet" every { this@mockk.accountId } returns accountIdMock From 5e3da8cc4776d8f4affb2c56855690ddaafe14cd Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Apr 2026 14:26:49 +0300 Subject: [PATCH 108/206] Updated on 2026-08-14 --- core/ui/src/main/res/drawable/ic_yield_40.xml | 79 +++++++++++ .../res/drawable/ic_yield_disabling_40.xml | 64 +++++++++ .../YieldSupplyToEarnBlockConverterTest.kt | 124 ++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 core/ui/src/main/res/drawable/ic_yield_40.xml create mode 100644 core/ui/src/main/res/drawable/ic_yield_disabling_40.xml create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt diff --git a/core/ui/src/main/res/drawable/ic_yield_40.xml b/core/ui/src/main/res/drawable/ic_yield_40.xml new file mode 100644 index 0000000000..24692470e4 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_yield_40.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/ui/src/main/res/drawable/ic_yield_disabling_40.xml b/core/ui/src/main/res/drawable/ic_yield_disabling_40.xml new file mode 100644 index 0000000000..27fc408f91 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_yield_disabling_40.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt new file mode 100644 index 0000000000..e50df7c3c7 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt @@ -0,0 +1,124 @@ +package com.tangem.features.yield.supply.impl.main.model.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import org.junit.jupiter.api.Test + +internal class YieldSupplyToEarnBlockConverterTest { + + private val converter = YieldSupplyToEarnBlockConverter() + + @Test + fun `GIVEN Initial WHEN convert THEN null`() { + val result = converter.convert(YieldSupplyUM.Initial) + + assertThat(result).isNull() + } + + @Test + fun `GIVEN Unavailable WHEN convert THEN null`() { + val result = converter.convert(YieldSupplyUM.Unavailable) + + assertThat(result).isNull() + } + + @Test + fun `GIVEN Loading WHEN convert THEN EarnBlockUM Loading`() { + val result = converter.convert(YieldSupplyUM.Loading) + + assertThat(result).isEqualTo(EarnBlockUM.Loading) + } + + @Test + fun `GIVEN Content WHEN convert THEN Content`() { + var clicked = false + val content = YieldSupplyUM.Content( + apy = "5.1", + title = stringReference("Yield Mode"), + subtitle = stringReference("Interest accrues automatically"), + rewardsApy = stringReference("APY 5.1%"), + onClick = { clicked = true }, + showWarningIcon = false, + showInfoIcon = false, + ) + + val result = converter.convert(content) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.type).isEqualTo(EarnBlockUM.Type.YieldSupply) + assertThat(earnBlock.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.Surface) + assertThat(earnBlock.iconUM).isInstanceOf(EarnBlockUM.IconUM.Glowing::class.java) + assertThat(earnBlock.titleUM.tone).isEqualTo(EarnBlockUM.TitleUM.Tone.Primary) + assertThat((earnBlock.subtitleUM as EarnBlockUM.SubtitleUM.Text).tone) + .isEqualTo(EarnBlockUM.SubtitleUM.Tone.Accent) + assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java) + val button = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Button + assertThat(button.isEnabled).isTrue() + assertThat(earnBlock.onClick).isNotNull() + earnBlock.onClick?.invoke() + assertThat(clicked).isTrue() + } + + @Test + fun `GIVEN Processing Enter WHEN convert THEN Surface Content`() { + val result = converter.convert(YieldSupplyUM.Processing.Enter) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.type).isEqualTo(EarnBlockUM.Type.YieldSupply) + assertThat(earnBlock.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.Surface) + assertThat(earnBlock.iconUM).isInstanceOf(EarnBlockUM.IconUM.Glowing::class.java) + assertThat(earnBlock.trailingUM).isNull() + } + + @Test + fun `GIVEN Processing Exit WHEN convert THEN Surface Content`() { + val result = converter.convert(YieldSupplyUM.Processing.Exit) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.type).isEqualTo(EarnBlockUM.Type.YieldSupply) + assertThat(earnBlock.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.Surface) + assertThat(earnBlock.iconUM).isInstanceOf(EarnBlockUM.IconUM.Plain::class.java) + assertThat(earnBlock.trailingUM).isNull() + } + + @Test + fun `GIVEN Available WHEN convert THEN Content with expected structure`() { + var clicked = false + val available = YieldSupplyUM.Available( + apy = "5.1", + apyText = stringReference("5.1 % APY"), + title = stringReference("Yield Mode"), + onClick = { clicked = true }, + ) + + val result = converter.convert(available) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val content = result as EarnBlockUM.Content + + assertThat(content.type).isEqualTo(EarnBlockUM.Type.YieldSupply) + assertThat(content.backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.AccentSoft) + assertThat(content.iconUM).isInstanceOf(EarnBlockUM.IconUM.Glowing::class.java) + + assertThat(content.titleUM.style).isEqualTo(EarnBlockUM.TitleUM.Style.Large) + assertThat(content.titleUM.tone).isEqualTo(EarnBlockUM.TitleUM.Tone.Primary) + + assertThat(content.subtitleUM).isInstanceOf(EarnBlockUM.SubtitleUM.Text::class.java) + val subtitle = content.subtitleUM as EarnBlockUM.SubtitleUM.Text + assertThat(subtitle.style).isEqualTo(EarnBlockUM.SubtitleUM.Style.Small) + assertThat(subtitle.tone).isEqualTo(EarnBlockUM.SubtitleUM.Tone.Accent) + + assertThat(content.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Button::class.java) + val button = content.trailingUM as EarnBlockUM.TrailingUM.Button + assertThat(button.isEnabled).isTrue() + + assertThat(content.onClick).isNotNull() + content.onClick?.invoke() + assertThat(clicked).isTrue() + } +} \ No newline at end of file From bc3443c479445d6ba52ba44c36c18acc059cf191 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 22 Apr 2026 15:03:36 +0300 Subject: [PATCH 109/206] Updated on 2026-08-14 --- .../com/tangem/common/ui/earn/EarnBlock.kt | 274 +++++++++++++----- .../com/tangem/common/ui/earn/EarnBlockUM.kt | 41 ++- .../DefaultTokenDetailsComponent.kt | 1 + .../UpdateStakingNotificationTransformer.kt | 48 +-- .../tokendetails/ui/TokenDetailsScreen.kt | 11 + features/yield-supply/impl/build.gradle.kts | 12 + .../impl/main/DefaultYieldSupplyComponent.kt | 14 +- .../impl/main/model/YieldSupplyModel.kt | 29 +- .../YieldSupplyToEarnBlockConverter.kt | 129 +++++++++ ...nt.kt => YieldSupplyBlockContentLegacy.kt} | 4 +- .../YieldSupplyToEarnBlockConverterTest.kt | 62 ++++ 11 files changed, 492 insertions(+), 133 deletions(-) create mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt rename features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/{YieldSupplyBlockContent.kt => YieldSupplyBlockContentLegacy.kt} (98%) diff --git a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt index 916fe0e737..e23fd4042e 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/earn/EarnBlock.kt @@ -6,24 +6,33 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.innerShadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import com.tangem.common.ui.earn.EarnBlockUM.Type import com.tangem.core.ui.R import com.tangem.core.ui.components.CircleShimmer import com.tangem.core.ui.components.RectangleShimmer @@ -47,9 +56,12 @@ import com.tangem.core.res.R as CoreResR private const val TINTED_BACKGROUND_ALPHA = 0.1f private const val TINTED_BORDER_ALPHA = 0.1f private const val TINTED_INNER_SHADOW_ALPHA = 0.3f +private const val GLOW_ALPHA = 0.7f private val BorderWidth = 1.dp private val InnerShadowBlur = 20.dp private val ShimmerSubtitleWidth = 78.dp +private val LoaderSize = 12.dp +private val LoaderStrokeWidth = 1.5.dp @Composable fun EarnBlock(state: EarnBlockUM, modifier: Modifier = Modifier) { @@ -95,92 +107,87 @@ private fun EarnBlockLoading(modifier: Modifier = Modifier) { private fun EarnBlockContent(state: EarnBlockUM.Content, modifier: Modifier = Modifier) { val shape = RoundedCornerShape(TangemTheme.dimens2.x4) - val backgroundModifier = resolveBackgroundModifier(state.backgroundUM, shape) - - val clickModifier = when (val trailing = state.trailingUM) { - is EarnBlockUM.TrailingUM.Balance -> Modifier.clickable(onClick = trailing.onClick) - else -> Modifier - } + val clickModifier = state.onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier TangemRowContainer( modifier = modifier .clip(shape) - .then(backgroundModifier) - .then(clickModifier), + .then(clickModifier.backgroundModifier(state.type, state.backgroundUM, shape)), contentPadding = PaddingValues(all = TangemTheme.dimens2.x3), content = { - // Icon (HEAD) EarnBlockIcon( + type = state.type, iconUM = state.iconUM, modifier = Modifier .layoutId(TangemRowLayoutId.HEAD) .padding(end = TangemTheme.dimens2.x3), ) - // Title (START_TOP) Text( text = state.titleUM.text.resolveReference(), - style = when (state.titleUM.style) { - EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 - EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 - }, - color = state.titleUM.color(), + style = state.titleUM.style.textStyle, + color = state.titleUM.tone.color(state.type), modifier = Modifier .layoutId(TangemRowLayoutId.START_TOP) .padding(end = TangemTheme.dimens2.x2), ) - // Subtitle (START_BOTTOM) val subtitle = state.subtitleUM if (subtitle is EarnBlockUM.SubtitleUM.Text) { - Text( - text = subtitle.text.resolveReference(), - style = when (subtitle.style) { - EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 - EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 - }, - color = subtitle.color(), + EarnBlockSubtitle( + subtitle = subtitle, + type = state.type, modifier = Modifier .layoutId(TangemRowLayoutId.START_BOTTOM) .padding(end = TangemTheme.dimens2.x2), ) } - // Trailing - EarnBlockTrailing(trailingUM = state.trailingUM) + EarnBlockTrailing(type = state.type, trailingUM = state.trailingUM, onClick = state.onClick) }, ) } @Composable -private fun resolveBackgroundModifier(backgroundUM: EarnBlockUM.BackgroundUM, shape: RoundedCornerShape): Modifier { +private fun Modifier.backgroundModifier( + type: Type, + backgroundUM: EarnBlockUM.BackgroundUM, + shape: RoundedCornerShape, +): Modifier { return when (backgroundUM) { - is EarnBlockUM.BackgroundUM.Surface -> Modifier + is EarnBlockUM.BackgroundUM.Surface -> this .background(TangemTheme.colors2.surface.level3) .border(width = BorderWidth, color = TangemTheme.colors2.border.neutral.primary, shape = shape) - is EarnBlockUM.BackgroundUM.Tinted -> { - val tintColor = backgroundUM.color() - Modifier - .background(tintColor.copy(alpha = TINTED_BACKGROUND_ALPHA)) - .border(width = BorderWidth, color = tintColor.copy(alpha = TINTED_BORDER_ALPHA), shape = shape) - .innerShadow( - shape = shape, - shadow = Shadow( - radius = InnerShadowBlur, - color = tintColor.copy(alpha = TINTED_INNER_SHADOW_ALPHA), - offset = DpOffset.Zero, - ), - ) - } + is EarnBlockUM.BackgroundUM.AccentSoft -> tintedBackground(type.accentSoftTint(), shape) + is EarnBlockUM.BackgroundUM.AccentStrong -> tintedBackground(type.accentStrongTint(), shape) } } +private fun Modifier.tintedBackground(tintColor: Color, shape: RoundedCornerShape): Modifier = this + .background(tintColor.copy(alpha = TINTED_BACKGROUND_ALPHA)) + .border(width = BorderWidth, color = tintColor.copy(alpha = TINTED_BORDER_ALPHA), shape = shape) + .innerShadow( + shape = shape, + shadow = Shadow( + radius = InnerShadowBlur, + color = tintColor.copy(alpha = TINTED_INNER_SHADOW_ALPHA), + offset = DpOffset.Zero, + ), + ) + @Composable -private fun EarnBlockTrailing(trailingUM: EarnBlockUM.TrailingUM?) { +private fun EarnBlockTrailing(type: Type, trailingUM: EarnBlockUM.TrailingUM?, onClick: (() -> Unit)?) { when (trailingUM) { is EarnBlockUM.TrailingUM.Button -> { TangemButton( - buttonUM = trailingUM.buttonUM, + buttonUM = TangemButtonUM( + text = trailingUM.text, + type = type.buttonType(), + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + isEnabled = trailingUM.isEnabled, + onClick = onClick ?: {}, + ), modifier = Modifier.layoutId(TangemRowLayoutId.TAIL), ) } @@ -200,12 +207,43 @@ private fun EarnBlockTrailing(trailingUM: EarnBlockUM.TrailingUM?) { ) } } + is EarnBlockUM.TrailingUM.Icon -> { + TangemIcon( + tangemIconUM = TangemIconUM.Icon( + iconRes = trailingUM.tone.iconRes(), + tintReference = { trailingUM.tone.tint() }, + ), + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .size(TangemTheme.dimens2.x6), + ) + } null -> Unit } } @Composable -private fun EarnBlockIcon(iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modifier) { +private fun EarnBlockSubtitle(subtitle: EarnBlockUM.SubtitleUM.Text, type: Type, modifier: Modifier = Modifier) { + val textStyle = subtitle.style.textStyle + val textColor = subtitle.tone.color(type) + if (subtitle.loader == null) { + Text(text = subtitle.text.resolveReference(), style = textStyle, color = textColor, modifier = modifier) + return + } + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Text(text = subtitle.text.resolveReference(), style = textStyle, color = textColor) + Spacer(modifier = Modifier.width(3.dp)) + CircularProgressIndicator( + color = subtitle.loader.tone.color(), + strokeWidth = LoaderStrokeWidth, + strokeCap = StrokeCap.Round, + modifier = Modifier.size(LoaderSize), + ) + } +} + +@Composable +private fun EarnBlockIcon(type: Type, iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modifier) { Box( contentAlignment = Alignment.Center, modifier = modifier.size(TangemTheme.dimens2.x10), @@ -216,7 +254,7 @@ private fun EarnBlockIcon(iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modif modifier = Modifier .size(TangemTheme.dimens2.x6) .blur(radius = TangemTheme.dimens2.x4, edgeTreatment = BlurredEdgeTreatment.Unbounded) - .background(color = iconUM.glowColor().copy(alpha = 0.7f), shape = glowShape), + .background(color = type.accentGlow().copy(alpha = GLOW_ALPHA), shape = glowShape), ) } val iconRes = when (iconUM) { @@ -230,6 +268,93 @@ private fun EarnBlockIcon(iconUM: EarnBlockUM.IconUM, modifier: Modifier = Modif } } +// region Type → theme mapping +@Composable +@ReadOnlyComposable +private fun Type.accentText(): Color = when (this) { + Type.Staking -> TangemTheme.colors2.text.status.accent + Type.YieldSupply -> TangemTheme.colors2.text.status.positive +} + +@Composable +@ReadOnlyComposable +private fun Type.accentGlow(): Color = when (this) { + Type.Staking -> TangemTheme.colors2.border.status.accent + Type.YieldSupply -> TangemTheme.colors2.text.status.positive +} + +@Composable +@ReadOnlyComposable +private fun Type.accentSoftTint(): Color = when (this) { + Type.Staking -> TangemTheme.colors2.markers.backgroundTintedBlue + Type.YieldSupply -> TangemTheme.colors2.markers.backgroundTintedGreen +} + +@Composable +@ReadOnlyComposable +private fun Type.accentStrongTint(): Color = when (this) { + Type.Staking -> TangemTheme.colors2.text.status.accent + Type.YieldSupply -> TangemTheme.colors2.text.status.positive +} + +private fun Type.buttonType(): TangemButtonType = when (this) { + Type.Staking -> TangemButtonType.Accent + Type.YieldSupply -> TangemButtonType.Positive +} + +@Composable +@ReadOnlyComposable +private fun EarnBlockUM.TitleUM.Tone.color(type: Type): Color = when (this) { + EarnBlockUM.TitleUM.Tone.Primary -> TangemTheme.colors2.text.neutral.primary + EarnBlockUM.TitleUM.Tone.Secondary -> TangemTheme.colors2.text.neutral.secondary + EarnBlockUM.TitleUM.Tone.Disabled -> TangemTheme.colors2.text.neutral.tertiary + EarnBlockUM.TitleUM.Tone.Accent -> type.accentText() +} + +@Composable +@ReadOnlyComposable +private fun EarnBlockUM.SubtitleUM.Tone.color(type: Type): Color = when (this) { + EarnBlockUM.SubtitleUM.Tone.Primary -> TangemTheme.colors2.text.neutral.primary + EarnBlockUM.SubtitleUM.Tone.Disabled -> TangemTheme.colors2.text.neutral.tertiary + EarnBlockUM.SubtitleUM.Tone.Accent -> type.accentText() +} + +private fun EarnBlockUM.TrailingUM.IconTone.iconRes(): Int = when (this) { + EarnBlockUM.TrailingUM.IconTone.Warning -> R.drawable.ic_alert_triangle_20 + EarnBlockUM.TrailingUM.IconTone.Info -> R.drawable.ic_alert_circle_red_20 +} + +@Composable +@ReadOnlyComposable +private fun EarnBlockUM.TrailingUM.IconTone.tint(): Color = when (this) { + EarnBlockUM.TrailingUM.IconTone.Warning -> TangemTheme.colors2.graphic.status.attention + EarnBlockUM.TrailingUM.IconTone.Info -> TangemTheme.colors2.fill.neutral.secondary +} + +@Composable +@ReadOnlyComposable +private fun EarnBlockUM.SubtitleUM.LoaderTone.color(): Color = when (this) { + EarnBlockUM.SubtitleUM.LoaderTone.Positive -> TangemTheme.colors2.text.status.positive + EarnBlockUM.SubtitleUM.LoaderTone.Muted -> TangemTheme.colors2.graphic.neutral.tertiaryConstant +} + +private val EarnBlockUM.TitleUM.Style.textStyle: TextStyle + @Composable + @ReadOnlyComposable + get() = when (this) { + EarnBlockUM.TitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.TitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 + } + +private val EarnBlockUM.SubtitleUM.Style.textStyle: TextStyle + @Composable + @ReadOnlyComposable + get() = when (this) { + EarnBlockUM.SubtitleUM.Style.Large -> TangemTheme.typography2.bodySemibold16 + EarnBlockUM.SubtitleUM.Style.Small -> TangemTheme.typography2.captionMedium12 + } +// endregion + // region Preview @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @@ -247,68 +372,79 @@ private class EarnBlockPreviewProvider : CollectionPreviewParameterProvider Unit)? = null, ) : EarnBlockUM + enum class Type { Staking, YieldSupply } + @Immutable sealed interface BackgroundUM { data object Surface : BackgroundUM - data class Tinted(val color: ColorReference2) : BackgroundUM + data object AccentSoft : BackgroundUM + data object AccentStrong : BackgroundUM } @Immutable sealed interface IconUM { - data class Glowing( - @DrawableRes val iconRes: Int, - val glowColor: ColorReference2, - ) : IconUM - - data class Plain( - @DrawableRes val iconRes: Int, - ) : IconUM + data class Glowing(@DrawableRes val iconRes: Int) : IconUM + data class Plain(@DrawableRes val iconRes: Int) : IconUM } @Immutable data class TitleUM( val text: TextReference, val style: Style, - val color: ColorReference2, + val tone: Tone, ) { enum class Style { Large, Small } + enum class Tone { Primary, Secondary, Disabled, Accent } } @Immutable @@ -51,21 +49,34 @@ sealed interface EarnBlockUM { data class Text( val text: TextReference, val style: Style, - val color: ColorReference2, + val tone: Tone, + val loader: Loader? = null, ) : SubtitleUM + data class Loader(val tone: LoaderTone) + enum class Style { Large, Small } + enum class Tone { Primary, Disabled, Accent } + enum class LoaderTone { Positive, Muted } } @Immutable sealed interface TrailingUM { - data class Button(val buttonUM: TangemButtonUM) : TrailingUM + data class Button( + val text: TextReference, + val isEnabled: Boolean = true, + ) : TrailingUM data class Balance( val fiatValue: TextReference, val cryptoValue: TextReference, val isBalanceHidden: Boolean, - val onClick: () -> Unit, ) : TrailingUM + + data class Icon( + val tone: IconTone, + ) : TrailingUM + + enum class IconTone { Warning, Info } } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index 035e4511bf..c2f0d12ba7 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -98,6 +98,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( TokenDetailsScreen( tokenDetailsUM = tokenDetailsUM, tokenMarketBlockComponent = tokenMarketBlockComponent, + yieldSupplyComponent = yieldSupplyComponent, modifier = modifier, ) } else { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt index abb2d6d3d3..46fbf2bac8 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateStakingNotificationTransformer.kt @@ -4,10 +4,6 @@ import androidx.compose.ui.text.SpanStyle import com.tangem.common.getRewardStakingBalance import com.tangem.common.getTotalStakingBalance import com.tangem.common.ui.earn.EarnBlockUM -import com.tangem.core.ui.ds.button.TangemButtonShape -import com.tangem.core.ui.ds.button.TangemButtonSize -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference @@ -58,17 +54,18 @@ internal class UpdateStakingNotificationTransformer( private fun buildTemporaryUnavailable(): EarnBlockUM.Content { return EarnBlockUM.Content( + type = EarnBlockUM.Type.Staking, backgroundUM = EarnBlockUM.BackgroundUM.Surface, iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_staking_disable_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.staking_native), style = EarnBlockUM.TitleUM.Style.Large, - color = { TangemTheme.colors2.text.neutral.tertiary }, + tone = EarnBlockUM.TitleUM.Tone.Disabled, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = resourceReference(CoreResR.string.staking_notification_network_error_text), style = EarnBlockUM.SubtitleUM.Style.Small, - color = { TangemTheme.colors2.text.neutral.tertiary }, + tone = EarnBlockUM.SubtitleUM.Tone.Disabled, ), trailingUM = null, ) @@ -118,31 +115,24 @@ internal class UpdateStakingNotificationTransformer( isEnabled: Boolean, ): EarnBlockUM.Content { return EarnBlockUM.Content( - backgroundUM = EarnBlockUM.BackgroundUM.Tinted { TangemTheme.colors2.markers.backgroundTintedBlue }, - iconUM = EarnBlockUM.IconUM.Glowing( - iconRes = CoreUiR.drawable.ic_staking_40, - glowColor = { TangemTheme.colors2.border.status.accent }, - ), + type = EarnBlockUM.Type.Staking, + backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(id = R.string.token_details_staking_block_title), style = EarnBlockUM.TitleUM.Style.Small, - color = { TangemTheme.colors2.text.status.accent }, + tone = EarnBlockUM.TitleUM.Tone.Accent, ), subtitleUM = EarnBlockUM.SubtitleUM.Text( text = stakeAvailableSubtitle(availability.option.displayApy), style = EarnBlockUM.SubtitleUM.Style.Large, - color = { TangemTheme.colors2.text.neutral.primary }, + tone = EarnBlockUM.SubtitleUM.Tone.Primary, ), trailingUM = EarnBlockUM.TrailingUM.Button( - buttonUM = TangemButtonUM( - text = resourceReference(R.string.common_stake), - type = TangemButtonType.Accent, - size = TangemButtonSize.X9, - shape = TangemButtonShape.Rounded, - isEnabled = isEnabled, - onClick = clickIntents::onStakeBannerClick, - ), + text = resourceReference(R.string.common_stake), + isEnabled = isEnabled, ), + onClick = clickIntents::onStakeBannerClick, ) } @@ -167,15 +157,13 @@ internal class UpdateStakingNotificationTransformer( val fiatAmount = stakingAmount?.let { fiatRate?.multiply(it) } val rewardFiatAmount = rewardAmount?.let { fiatRate?.multiply(it) } return EarnBlockUM.Content( + type = EarnBlockUM.Type.Staking, backgroundUM = EarnBlockUM.BackgroundUM.Surface, - iconUM = EarnBlockUM.IconUM.Glowing( - iconRes = CoreUiR.drawable.ic_staking_40, - glowColor = { TangemTheme.colors2.border.status.accent }, - ), + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_staking_40), titleUM = EarnBlockUM.TitleUM( text = resourceReference(CoreResR.string.staking_native), style = EarnBlockUM.TitleUM.Style.Large, - color = { TangemTheme.colors2.text.neutral.primary }, + tone = EarnBlockUM.TitleUM.Tone.Primary, ), subtitleUM = getRewardSubtitle(status, rewardFiatAmount), trailingUM = EarnBlockUM.TrailingUM.Balance( @@ -197,8 +185,8 @@ internal class UpdateStakingNotificationTransformer( }, ), isBalanceHidden = isBalanceHidden, - onClick = clickIntents::onStakeBannerClick, ), + onClick = clickIntents::onStakeBannerClick, ) } @@ -262,11 +250,7 @@ internal class UpdateStakingNotificationTransformer( return EarnBlockUM.SubtitleUM.Text( text = text, style = EarnBlockUM.SubtitleUM.Style.Small, - color = if (isAccent) { - { TangemTheme.colors2.text.status.accent } - } else { - { TangemTheme.colors2.text.neutral.tertiary } - }, + tone = if (isAccent) EarnBlockUM.SubtitleUM.Tone.Accent else EarnBlockUM.SubtitleUM.Tone.Disabled, ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index 0783260183..ac99df1076 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -56,6 +56,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.yield.supply.api.YieldSupplyComponent import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeTint import kotlinx.collections.immutable.persistentListOf @@ -67,6 +68,7 @@ private val MarketBlockHorizontalPadding: Dp = 14.dp internal fun TokenDetailsScreen( tokenDetailsUM: TokenDetailsUM, tokenMarketBlockComponent: TokenMarketBlockComponent?, + yieldSupplyComponent: YieldSupplyComponent, modifier: Modifier = Modifier, ) { val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() } @@ -107,6 +109,7 @@ internal fun TokenDetailsScreen( body = { TokenDetailsBody( tokenDetailsUM = tokenDetailsUM, + yieldSupplyComponent = yieldSupplyComponent, rootBackground = rootBackground, bottomContentPadding = marketBlockHeight, modifier = Modifier @@ -193,6 +196,7 @@ private fun BoxScope.TokenDetailsMarketBlockOverlay( @Composable private fun TokenDetailsBody( tokenDetailsUM: TokenDetailsUM, + yieldSupplyComponent: YieldSupplyComponent, rootBackground: Color, bottomContentPadding: Dp, modifier: Modifier = Modifier, @@ -215,6 +219,9 @@ private fun TokenDetailsBody( ) } } + item(key = "yield_supply_block") { + yieldSupplyComponent.Content(modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x2)) + } // TODO [REDACTED_TASK_KEY] Token Details Make Transaction History } } @@ -262,6 +269,10 @@ private fun TokenDetailsScreen_Preview() { isBalanceHidden = false, isMarketPriceAvailable = true, ), + yieldSupplyComponent = object : YieldSupplyComponent { + @Composable + override fun Content(modifier: Modifier) = Unit + }, ) } } diff --git a/features/yield-supply/impl/build.gradle.kts b/features/yield-supply/impl/build.gradle.kts index d71b9cf36a..4bf8eda135 100644 --- a/features/yield-supply/impl/build.gradle.kts +++ b/features/yield-supply/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.yield.supply.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Feature */ @@ -20,6 +24,7 @@ dependencies { implementation(projects.core.configToggles) implementation(projects.core.datasource) implementation(projects.core.decompose) + implementation(projects.core.res) implementation(projects.core.ui) implementation(projects.core.navigation) implementation(projects.core.analytics) @@ -75,4 +80,11 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt index 2473062b59..15c94cd9ac 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/DefaultYieldSupplyComponent.kt @@ -4,11 +4,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.earn.EarnBlock import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.impl.main.model.YieldSupplyModel -import com.tangem.features.yield.supply.impl.main.ui.YieldSupplyBlockContent +import com.tangem.features.yield.supply.impl.main.ui.YieldSupplyBlockContentLegacy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -22,9 +24,13 @@ internal class DefaultYieldSupplyComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val yieldSupplyUM by model.uiState.collectAsStateWithLifecycle() - - YieldSupplyBlockContent(yieldSupplyUM = yieldSupplyUM, modifier = modifier) + if (LocalRedesignEnabled.current) { + val earnBlockUM by model.uiState.collectAsStateWithLifecycle() + earnBlockUM?.let { EarnBlock(state = it, modifier = modifier) } + } else { + val yieldSupplyUM by model.uiStateLegacy.collectAsStateWithLifecycle() + YieldSupplyBlockContentLegacy(yieldSupplyUM = yieldSupplyUM, modifier = modifier) + } } @AssistedFactory diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 70194d75cc..03dbe46227 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -30,7 +30,9 @@ import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R +import com.tangem.common.ui.earn.EarnBlockUM import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.features.yield.supply.impl.main.model.converter.YieldSupplyToEarnBlockConverter import com.tangem.features.yield.supply.impl.main.model.transformers.YieldSupplyTokenStatusSuccessTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.transformer.update @@ -61,11 +63,16 @@ internal class YieldSupplyModel @Inject constructor( private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, ) : Model(), YieldSupplyClickIntents { + private val earnBlockConverter = YieldSupplyToEarnBlockConverter() private val params = paramsContainer.require() - val uiState: StateFlow + val uiStateLegacy: StateFlow field = MutableStateFlow(YieldSupplyUM.Initial) + val uiState: StateFlow = uiStateLegacy + .map(earnBlockConverter::convert) + .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) + private val cryptoCurrency = params.cryptoCurrency private var appCurrency: AppCurrency = AppCurrency.Default var userWallet: UserWallet by Delegates.notNull() @@ -143,7 +150,7 @@ internal class YieldSupplyModel @Inject constructor( val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) .onRight { tokenStatus -> - uiState.update( + uiStateLegacy.update( YieldSupplyTokenStatusSuccessTransformer( tokenStatus = tokenStatus, onStartEarningClick = ::onStartEarningClick, @@ -151,7 +158,7 @@ internal class YieldSupplyModel @Inject constructor( ) }.onLeft { error -> TangemLogger.e("Error", error) - uiState.update { YieldSupplyUM.Initial } + uiStateLegacy.update { YieldSupplyUM.Initial } } } @@ -165,7 +172,7 @@ internal class YieldSupplyModel @Inject constructor( private fun navigateToYieldSupplyEntry() { val cryptoCurrencyStatus = latestCryptoCurrencyStatus ?: return - val apy = when (val yieldSupplyUM = uiState.value) { + val apy = when (val yieldSupplyUM = uiStateLegacy.value) { is YieldSupplyUM.Available -> yieldSupplyUM.apy is YieldSupplyUM.Content -> yieldSupplyUM.apy else -> "" @@ -181,8 +188,8 @@ internal class YieldSupplyModel @Inject constructor( private suspend fun onCryptoCurrencyStatusUpdated(cryptoCurrencyStatus: CryptoCurrencyStatus) { val isCryptoCurrencyStatusFromCache = cryptoCurrencyStatus.value.sources.networkSource != StatusSource.ACTUAL - val processing = uiState.value is YieldSupplyUM.Processing - if (isCryptoCurrencyStatusFromCache && processing) { + val isProcessing = uiStateLegacy.value is YieldSupplyUM.Processing + if (isCryptoCurrencyStatusFromCache && isProcessing) { return } @@ -199,7 +206,7 @@ internal class YieldSupplyModel @Inject constructor( } private fun showProcessing(status: YieldSupplyPendingStatus) { - uiState.update { + uiStateLegacy.update { when (status) { is YieldSupplyPendingStatus.Enter -> YieldSupplyUM.Processing.Enter is YieldSupplyPendingStatus.Exit -> YieldSupplyUM.Processing.Exit @@ -225,7 +232,7 @@ internal class YieldSupplyModel @Inject constructor( ) { val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return val showWarningIcon = !yieldSupplyStatus.isAllowedToSpend - val isShowInfoIconPrevState = when (val state = uiState.value) { + val isShowInfoIconPrevState = when (val state = uiStateLegacy.value) { is YieldSupplyUM.Content -> state.showInfoIcon else -> false } @@ -239,7 +246,7 @@ internal class YieldSupplyModel @Inject constructor( } yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) .onRight { tokenStatus -> - uiState.update { + uiStateLegacy.update { YieldSupplyUM.Content( title = resourceReference( R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, @@ -262,7 +269,7 @@ internal class YieldSupplyModel @Inject constructor( computeAndApplyShowInfoIcon(cryptoCurrencyStatus) }.onLeft { t -> TangemLogger.e("Error", t) - uiState.update { + uiStateLegacy.update { YieldSupplyUM.Content( title = resourceReference( R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title, @@ -299,7 +306,7 @@ internal class YieldSupplyModel @Inject constructor( } else { false } - uiState.update { state -> + uiStateLegacy.update { state -> when (state) { is YieldSupplyUM.Content -> state.copy(showInfoIcon = isShowInfoIcon) else -> state diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt new file mode 100644 index 0000000000..1a86207510 --- /dev/null +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverter.kt @@ -0,0 +1,129 @@ +package com.tangem.features.yield.supply.impl.main.model.converter + +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.utils.converter.Converter +import com.tangem.core.res.R as CoreResR +import com.tangem.core.ui.R as CoreUiR + +internal class YieldSupplyToEarnBlockConverter : Converter { + + override fun convert(value: YieldSupplyUM): EarnBlockUM? = when (value) { + is YieldSupplyUM.Initial, + is YieldSupplyUM.Unavailable, + -> null + is YieldSupplyUM.Available -> buildAvailable(value) + is YieldSupplyUM.Content -> buildContent(value) + is YieldSupplyUM.Processing.Enter -> buildProcessingEnter() + is YieldSupplyUM.Processing.Exit -> buildProcessingExit() + is YieldSupplyUM.Loading -> EarnBlockUM.Loading + } + + private fun buildAvailable(value: YieldSupplyUM.Available): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.YieldSupply, + backgroundUM = EarnBlockUM.BackgroundUM.AccentSoft, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference( + id = CoreResR.string.yield_module_token_details_earn_notification_subtitle, + formatArgs = wrappedList(value.apy), + ), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference( + CoreResR.string.yield_module_token_details_earn_notification_description, + ), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Accent, + ), + trailingUM = EarnBlockUM.TrailingUM.Button( + text = resourceReference(CoreResR.string.common_more), + ), + onClick = value.onClick, + ) + } + + private fun buildContent(value: YieldSupplyUM.Content): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.YieldSupply, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40), + titleUM = EarnBlockUM.TitleUM( + text = if (value.showWarningIcon) { + resourceReference(CoreResR.string.common_yield_mode) + } else { + resourceReference(CoreResR.string.yield_module_transaction_enter) + }, + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference( + id = CoreResR.string.yield_module_average_apy, + formatArgs = wrappedList(value.apy), + ), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Accent, + ), + trailingUM = buildContentTrailing(value), + onClick = value.onClick, + ) + } + + private fun buildContentTrailing(value: YieldSupplyUM.Content): EarnBlockUM.TrailingUM? = when { + value.showWarningIcon -> EarnBlockUM.TrailingUM.Icon( + tone = EarnBlockUM.TrailingUM.IconTone.Warning, + ) + value.showInfoIcon -> EarnBlockUM.TrailingUM.Icon( + tone = EarnBlockUM.TrailingUM.IconTone.Info, + ) + else -> EarnBlockUM.TrailingUM.Button( + text = resourceReference(CoreResR.string.details_title), + ) + } + + private fun buildProcessingEnter(): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.YieldSupply, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Glowing(iconRes = CoreUiR.drawable.ic_yield_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.common_yield_mode), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.common_enabling), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Accent, + loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Positive), + ), + trailingUM = null, + ) + } + + private fun buildProcessingExit(): EarnBlockUM.Content { + return EarnBlockUM.Content( + type = EarnBlockUM.Type.YieldSupply, + backgroundUM = EarnBlockUM.BackgroundUM.Surface, + iconUM = EarnBlockUM.IconUM.Plain(iconRes = CoreUiR.drawable.ic_yield_disabling_40), + titleUM = EarnBlockUM.TitleUM( + text = resourceReference(CoreResR.string.common_yield_mode), + style = EarnBlockUM.TitleUM.Style.Large, + tone = EarnBlockUM.TitleUM.Tone.Primary, + ), + subtitleUM = EarnBlockUM.SubtitleUM.Text( + text = resourceReference(CoreResR.string.common_disabling), + style = EarnBlockUM.SubtitleUM.Style.Small, + tone = EarnBlockUM.SubtitleUM.Tone.Disabled, + loader = EarnBlockUM.SubtitleUM.Loader(tone = EarnBlockUM.SubtitleUM.LoaderTone.Muted), + ), + trailingUM = null, + ) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt similarity index 98% rename from features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt rename to features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt index 36a30575b7..7286fa3c1e 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContent.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/ui/YieldSupplyBlockContentLegacy.kt @@ -38,7 +38,7 @@ import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM import com.tangem.utils.StringsSigns @Composable -internal fun YieldSupplyBlockContent(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) { +internal fun YieldSupplyBlockContentLegacy(yieldSupplyUM: YieldSupplyUM, modifier: Modifier = Modifier) { AnimatedContent( targetState = yieldSupplyUM, contentKey = { it::class }, @@ -330,7 +330,7 @@ private fun SupplyInfo( @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun YieldSupplyBlockContent_Preview(@PreviewParameter(PreviewProvider::class) params: YieldSupplyUM) { TangemThemePreview { - YieldSupplyBlockContent(yieldSupplyUM = params, modifier = Modifier) + YieldSupplyBlockContentLegacy(yieldSupplyUM = params, modifier = Modifier) } } diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt index e50df7c3c7..3a44708e54 100644 --- a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/converter/YieldSupplyToEarnBlockConverterTest.kt @@ -86,6 +86,68 @@ internal class YieldSupplyToEarnBlockConverterTest { assertThat(earnBlock.trailingUM).isNull() } + @Test + fun `GIVEN Content with showWarningIcon WHEN convert THEN trailing Warning Icon`() { + val content = YieldSupplyUM.Content( + apy = "5.1", + title = stringReference("Yield Mode"), + subtitle = stringReference("Interest accrues automatically"), + rewardsApy = stringReference("APY 5.1%"), + onClick = {}, + showWarningIcon = true, + showInfoIcon = false, + ) + + val result = converter.convert(content) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Icon::class.java) + val icon = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Icon + assertThat(icon.tone).isEqualTo(EarnBlockUM.TrailingUM.IconTone.Warning) + } + + @Test + fun `GIVEN Content with showInfoIcon WHEN convert THEN trailing Info Icon`() { + val content = YieldSupplyUM.Content( + apy = "5.1", + title = stringReference("Yield Mode"), + subtitle = stringReference("Interest accrues automatically"), + rewardsApy = stringReference("APY 5.1%"), + onClick = {}, + showWarningIcon = false, + showInfoIcon = true, + ) + + val result = converter.convert(content) + + assertThat(result).isInstanceOf(EarnBlockUM.Content::class.java) + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Icon::class.java) + val icon = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Icon + assertThat(icon.tone).isEqualTo(EarnBlockUM.TrailingUM.IconTone.Info) + } + + @Test + fun `GIVEN Content with both icons WHEN convert THEN Warning takes precedence`() { + val content = YieldSupplyUM.Content( + apy = "5.1", + title = stringReference("Yield Mode"), + subtitle = stringReference("Interest accrues automatically"), + rewardsApy = stringReference("APY 5.1%"), + onClick = {}, + showWarningIcon = true, + showInfoIcon = true, + ) + + val result = converter.convert(content) + + val earnBlock = result as EarnBlockUM.Content + assertThat(earnBlock.trailingUM).isInstanceOf(EarnBlockUM.TrailingUM.Icon::class.java) + val icon = earnBlock.trailingUM as EarnBlockUM.TrailingUM.Icon + assertThat(icon.tone).isEqualTo(EarnBlockUM.TrailingUM.IconTone.Warning) + } + @Test fun `GIVEN Available WHEN convert THEN Content with expected structure`() { var clicked = false From 2e0bdac248dd65c2307a3b4c27839f0a399ff955 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 24 Apr 2026 05:01:25 -0700 Subject: [PATCH 110/206] Updated on 2026-08-14 --- .../tangem/datasource/api/pay/TangemPayApi.kt | 7 +- .../request/UpdateCardDisplayNameRequest.kt | 9 - .../pay/models/request/UpdateCardRequest.kt | 15 ++ core/res/src/main/res/values/strings.xml | 2 + .../tangem/data/pay/di/TangemPayDataModule.kt | 9 + .../DefaultTangemPayCardDetailsRepository.kt | 36 +++- .../account/PaymentAccountStatusValue.kt | 9 +- .../TangemPayCardDetailsRepository.kt | 7 + .../usecase/SetTangemPayCardLimitUseCase.kt | 25 +++ .../DefaultTangemPayCardPageComponent.kt | 12 ++ ...faultTangemPayDetailsContainerComponent.kt | 9 + .../tangempay/di/TangemPayModelModule.kt | 6 + .../setup/TangemPayCardLimitSetupComponent.kt | 29 +++ .../setup/TangemPayCardLimitSetupModel.kt | 195 ++++++++++++++++++ .../setup/TangemPayCardLimitSetupScreen.kt | 182 ++++++++++++++++ ...TangemPayCardLimitSetupSuccessComponent.kt | 26 +++ .../TangemPayCardLimitSetupSuccessScreen.kt | 102 +++++++++ .../limit/setup/TangemPayCardLimitSetupUM.kt | 64 ++++++ .../tangempay/model/TangemPayCardPageModel.kt | 10 +- .../model/TangemPayEditDisplayNameModel.kt | 22 +- .../navigation/TangemPayDetailsInnerRoute.kt | 6 + 21 files changed, 752 insertions(+), 30 deletions(-) delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardRequest.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreen.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreen.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupUM.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index bcbcd299b2..56359bc593 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -109,9 +109,10 @@ interface TangemPayApi { @Body body: WithdrawRequest, ): ApiResponse - @PATCH("v1/card") - suspend fun updateCardDisplayName( + @PATCH("v1/customer/card/{card_id}") + suspend fun updateCard( @Header("Authorization") authHeader: String, - @Body body: UpdateCardDisplayNameRequest, + @Body body: UpdateCardRequest, + @Path("card_id") cardId: String, ): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.kt deleted file mode 100644 index b5c9e74e58..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardDisplayNameRequest.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.tangem.datasource.api.pay.models.request - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class UpdateCardDisplayNameRequest( - @Json(name = "display_name") val displayName: String, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardRequest.kt new file mode 100644 index 0000000000..3dc16b32d0 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/request/UpdateCardRequest.kt @@ -0,0 +1,15 @@ +package com.tangem.datasource.api.pay.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UpdateCardRequest( + @Json(name = "display_name") val displayName: String? = null, + @Json(name = "card_limit") val cardLimit: CardLimit? = null, +) { + @JsonClass(generateAdapter = true) + data class CardLimit( + @Json(name = "amount") val amount: String, + ) +} \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 5bfd090643..b3f833f9fd 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1676,6 +1676,8 @@ Daily limit is set Daily limit Card settings + Set a limit from %1$s + We couldn’t set the limit. Please try again. Change PIN-code Come back to the app if you forget it. Set a limit from %s to %s diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 2c3e46bd19..97394190aa 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -29,6 +29,7 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.* import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase +import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase @@ -150,6 +151,14 @@ internal interface TangemPayDataModule { ) {} } + @Provides + fun provideSetTangemPayCardLimitUseCase( + cardDetailsRepository: TangemPayCardDetailsRepository, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + ): SetTangemPayCardLimitUseCase { + return SetTangemPayCardLimitUseCase(cardDetailsRepository, paymentAccountStatusFetcher) + } + @Provides @Singleton fun provideGetTangemPayCryptoCurrencyStatusUseCase( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt index 814476fa34..97657f4c0d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -14,8 +14,8 @@ import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.CardDetailsRequest import com.tangem.datasource.api.pay.models.request.FreezeUnfreezeCardRequest +import com.tangem.datasource.api.pay.models.request.UpdateCardRequest import com.tangem.datasource.api.pay.models.request.SetPinRequest -import com.tangem.datasource.api.pay.models.request.UpdateCardDisplayNameRequest import com.tangem.datasource.api.pay.models.response.FreezeUnfreezeCardResponse import com.tangem.datasource.api.pay.models.response.OrderResponse.Result.Status import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore @@ -41,7 +41,7 @@ import kotlin.time.Duration.Companion.seconds private const val TAG = "TangemPay: CardDetailsRepository" -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class DefaultTangemPayCardDetailsRepository @Inject constructor( private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, @@ -321,15 +321,43 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( } override suspend fun updateCardDisplayName( + cardId: String, userWalletId: UserWalletId, displayName: CardDisplayName, ): Either { return catch( block = { requestHelper.performRequest(userWalletId) { authHeader -> - tangemPayApi.updateCardDisplayName( + tangemPayApi.updateCard( authHeader = authHeader, - body = UpdateCardDisplayNameRequest(displayName = displayName.value), + body = UpdateCardRequest( + displayName = displayName.value, + ), + cardId = cardId, + ) + }.fold( + ifLeft = { error -> error.left() }, + ifRight = { Unit.right() }, + ) + }, + catch = ::catchException, + ) + } + + override suspend fun updateCardLimit( + cardId: String, + userWalletId: UserWalletId, + limit: String, + ): Either { + return catch( + block = { + requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.updateCard( + authHeader = authHeader, + body = UpdateCardRequest( + cardLimit = UpdateCardRequest.CardLimit(limit), + ), + cardId = cardId, ) }.fold( ifLeft = { error -> error.left() }, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index 6187861ae1..15029d713b 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -2,6 +2,7 @@ package com.tangem.domain.models.account import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TotalFiatBalance +import com.tangem.domain.models.account.PaymentAccountStatusValue.Loaded import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.kyc.KycStatus @@ -185,4 +186,10 @@ sealed class PaymentAccountStatusValue { val tokenContractAddress: String, val balance: SerializedBigDecimal, ) -} \ No newline at end of file +} + +fun Loaded.hasCardWithId(cardId: String): Boolean = cards.any { it.id == cardId } + +fun Loaded.findCardWithId(cardId: String): TangemPayCard? = cards.firstOrNull { it.id == cardId } + +fun Loaded.requireCardWithId(cardId: String): TangemPayCard = requireNotNull(findCardWithId(cardId)) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt index 80aa3ea5c9..6778223272 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt @@ -34,7 +34,14 @@ interface TangemPayCardDetailsRepository { suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? suspend fun updateCardDisplayName( + cardId: String, userWalletId: UserWalletId, displayName: CardDisplayName, ): Either + + suspend fun updateCardLimit( + cardId: String, + userWalletId: UserWalletId, + limit: String, + ): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt new file mode 100644 index 0000000000..9c66f8d75e --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import java.math.BigDecimal + +class SetTangemPayCardLimitUseCase( + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, +) { + suspend operator fun invoke( + cardId: String, + userWalletId: UserWalletId, + amount: BigDecimal, + ): Either { + return cardDetailsRepository.updateCardLimit(cardId, userWalletId, amount.toPlainString()) + .onRight { + val params = PaymentAccountStatusFetcher.Params(userWalletId) + paymentAccountStatusFetcher.invoke(params) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt index de2c4bf4a2..7271f7fd3a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt @@ -13,6 +13,8 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupComponent +import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupSuccessComponent import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted @@ -84,6 +86,16 @@ internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( config = params.config, ), ) + TangemPayDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayDetailsContainerComponent.Params( + userWalletId = params.userWalletId, + config = params.config, + ), + ) + TangemPayDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 35ea8d9089..272c8d3b39 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -16,6 +16,8 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.tangempay.components.express.ExpressTransactionsComponentProvider +import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupComponent +import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupSuccessComponent import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted @@ -82,6 +84,13 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru config = params.config, ), ) + TangemPayDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = params, + ) + TangemPayDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt index d9b0f67529..1766696a25 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayModelModule.kt @@ -6,6 +6,7 @@ import com.tangem.features.tangempay.model.TangemPayAddFundsModel import com.tangem.features.tangempay.model.TangemPayAddToWalletModel import com.tangem.features.tangempay.model.TangemPayCardDetailsBlockModel import com.tangem.features.tangempay.model.TangemPayCardPageModel +import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupModel import com.tangem.features.tangempay.model.TangemPayChangePinModel import com.tangem.features.tangempay.model.TangemPayDetailsModel import com.tangem.features.tangempay.model.TangemPayEditDisplayNameModel @@ -77,4 +78,9 @@ internal interface TangemPayModelModule { @IntoMap @ClassKey(TangemPayReissueCardModel::class) fun bindTangemPayReissueCardModel(model: TangemPayReissueCardModel): Model + + @Binds + @IntoMap + @ClassKey(TangemPayCardLimitSetupModel::class) + fun bindTangemPayCardLimitSetupModel(model: TangemPayCardLimitSetupModel): Model } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupComponent.kt new file mode 100644 index 0000000000..2916fd93ed --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupComponent.kt @@ -0,0 +1,29 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent + +internal class TangemPayCardLimitSetupComponent( + appComponentContext: AppComponentContext, + params: TangemPayDetailsContainerComponent.Params, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + private val model: TangemPayCardLimitSetupModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + BackHandler(onBack = router::pop) + TangemPayCardLimitSetupScreen( + state = state, + modifier = modifier, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt new file mode 100644 index 0000000000..509ea0514e --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -0,0 +1,195 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.compose.runtime.Stable +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.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.format.bigdecimal.anyDecimals +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.findCardWithId +import com.tangem.domain.models.account.requireCardWithId +import com.tangem.domain.models.pay.TangemPayCardLimitPeriod +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.util.Currency +import javax.inject.Inject + +@Stable +@ModelScoped +internal class TangemPayCardLimitSetupModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, + private val setTangemPayCardLimitUseCase: SetTangemPayCardLimitUseCase, + private val uiMessageSender: UiMessageSender, +) : Model() { + + private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow( + TangemPayCardLimitSetupUM( + isInitialDataLoading = true, + amountFieldModel = TangemPayCardLimitSetupUM.AmountFieldModel( + value = "", + decimals = 0, + onValueChange = {}, + ), + subtitle = TextReference.EMPTY, + currencyCode = "", + presets = persistentListOf(), + isSubmitButtonEnabled = false, + isSubmitButtonLoading = false, + onSubmitClick = ::onSubmitClick, + onBackClick = router::pop, + ), + ) + + init { + observeCardState() + } + + private fun observeCardState() { + paymentAccountStatusSupplier.invoke(params.userWalletId) + .map { it.value } + .filterIsInstance() + .filter { status -> + status.source == StatusSource.ACTUAL && status.findCardWithId(params.config.cardId) != null + } + .withIndex() + .onEach { (index, status) -> + val card = status.requireCardWithId(params.config.cardId) + + val currentLimit = card.limit?.actualCardLimit + ?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } + ?.amount + + val adminLimit = card.limit?.adminCardLimit + ?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } + ?.amount + + val currency = getJavaCurrencyByCode(status.currencyCode) + uiState.update { state -> + val amount = if (index == 0) { + currentLimit?.stripTrailingZeros()?.toPlainString().orEmpty() + } else { + state.amountFieldModel.value + } + state.copy( + isInitialDataLoading = false, + amountFieldModel = TangemPayCardLimitSetupUM.AmountFieldModel( + value = amount, + decimals = currency.defaultFractionDigits, + onValueChange = ::onAmountChange, + ), + subtitle = buildSubtitle(adminLimit, currency), + currencyCode = currency.symbol, + presets = buildPresets(currency), + isSubmitButtonEnabled = isValid(amount), + ) + } + } + .launchIn(modelScope) + } + + private fun onAmountChange(newValue: String) { + uiState.update { state -> + state.copy( + amountFieldModel = state.amountFieldModel.copy(value = newValue), + isSubmitButtonEnabled = isValid(newValue), + ) + } + } + + private fun onPresetClick(preset: BigDecimal) { + val amount = uiState.value.amountFieldModel.value.toBigDecimalOrNull() ?: return + val newAmount = if (preset == BigDecimal.ZERO) BigDecimal.ZERO else amount + preset + onAmountChange(newAmount.stripTrailingZeros().toPlainString()) + } + + private fun onSubmitClick() { + val amount = uiState.value.amountFieldModel.value.toBigDecimalOrNull() ?: return + modelScope.launch { + uiState.update { it.copy(isSubmitButtonLoading = true) } + setTangemPayCardLimitUseCase( + cardId = params.config.cardId, + userWalletId = params.userWalletId, + amount = amount, + ).fold( + ifLeft = { + uiState.update { state -> state.copy(isSubmitButtonLoading = false) } + uiMessageSender.send( + DialogMessage( + title = TextReference.Res(R.string.common_something_went_wrong), + message = TextReference.Res(R.string.tangempay_card_limit_setup_error_message), + ), + ) + }, + ifRight = { + uiState.update { state -> state.copy(isSubmitButtonLoading = false) } + router.push(TangemPayDetailsInnerRoute.LimitSetupSuccess) + }, + ) + } + } + + private fun isValid(value: String): Boolean { + val amount = value.toBigDecimalOrNull() ?: return false + return amount >= BigDecimal.ZERO + } + + private fun buildSubtitle(maxLimit: BigDecimal?, currency: Currency): TextReference { + return if (maxLimit == null) { + TextReference.Res( + id = R.string.tangempay_card_limit_setup_amount_subtitle, + formatArgs = WrappedList( + listOf( + BigDecimal.ZERO.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + ), + ), + ) + } else { + TextReference.Res( + id = R.string.tangempay_daily_limit_hint, + formatArgs = WrappedList( + listOf( + BigDecimal.ZERO.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + maxLimit.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + ), + ), + ) + } + } + + private fun buildPresets(currency: Currency) = listOf( + BigDecimal.ZERO, + BigDecimal("5000"), + BigDecimal("10000"), + BigDecimal("25000"), + ).map { preset -> + val label = preset.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) } + TangemPayCardLimitSetupUM.LimitPresetUM( + label = if (preset == BigDecimal.ZERO) "0" else "+$label", + onClick = { onPresetClick(preset) }, + ) + }.toPersistentList() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreen.kt new file mode 100644 index 0000000000..3ac9114bd2 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupScreen.kt @@ -0,0 +1,182 @@ +package com.tangem.features.tangempay.limit.setup + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.components.fields.AmountTextField +import com.tangem.core.ui.components.fields.visualtransformations.AmountVisualTransformation +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.utils.rememberDecimalFormat +import com.tangem.features.tangempay.details.impl.R +import kotlinx.collections.immutable.ImmutableList + +@Composable +internal fun TangemPayCardLimitSetupScreen(state: TangemPayCardLimitSetupUM, modifier: Modifier = Modifier) { + Scaffold( + modifier = modifier, + topBar = { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + title = stringResourceSafe(R.string.tangempay_card_page_daily_limit_title), + startButton = TopAppBarButtonUM.Close(onCloseClick = state.onBackClick), + ) + }, + containerColor = TangemTheme.colors.background.secondary, + ) { scaffoldPaddings -> + Content( + state = state, + modifier = Modifier.padding(scaffoldPaddings), + ) + } +} + +@Composable +private fun Content(state: TangemPayCardLimitSetupUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .imePadding(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AmountBlock( + modifier = Modifier.padding(horizontal = 16.dp), + state = state, + ) + Spacer(modifier = Modifier.weight(1f)) + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResourceSafe(R.string.tangempay_daily_limit_set_button), + enabled = state.isSubmitButtonEnabled, + showProgress = state.isSubmitButtonLoading, + onClick = state.onSubmitClick, + ) + PresetsRow(presets = state.presets) + } +} + +@Composable +private fun AmountBlock(state: TangemPayCardLimitSetupUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(TangemTheme.colors.background.action) + .padding(vertical = 48.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResourceSafe(R.string.common_amount), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.tertiary, + ) + SpacerH12() + if (state.isInitialDataLoading) { + TextShimmer( + style = TangemTheme.typography.head, + text = "$5000", + ) + } else { + AmountTextField( + value = state.amountFieldModel.value, + decimals = state.amountFieldModel.decimals, + onValueChange = state.amountFieldModel.onValueChange, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + visualTransformation = AmountVisualTransformation( + decimals = state.amountFieldModel.decimals, + symbol = state.currencyCode, + currencyCode = state.currencyCode, + decimalFormat = rememberDecimalFormat(), + symbolColor = if (state.amountFieldModel.value.isBlank()) { + TangemTheme.colors.text.disabled + } else { + TangemTheme.colors.text.primary1 + }, + ), + textStyle = TangemTheme.typography.head.copy( + textAlign = TextAlign.Center, + ), + isAutoResize = true, + ) + } + Spacer(modifier = Modifier.padding(top = 8.dp)) + Text( + text = state.subtitle.resolveReference(), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun PresetsRow(presets: ImmutableList) { + if (presets.isEmpty()) return + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = TangemTheme.colors.button.secondary) + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + presets.forEach { preset -> + PresetChip( + preset = preset, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun PresetChip(preset: TangemPayCardLimitSetupUM.LimitPresetUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors.background.primary) + .clickable(onClick = preset.onClick) + .padding(horizontal = 12.dp, vertical = 4.dp) + .wrapContentHeight(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = preset.label, + style = TangemTheme.typography.caption1, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun preview() = TangemThemePreview { + TangemPayCardLimitSetupScreen( + state = TangemPayCardLimitSetupUM.stub(), + ) +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt new file mode 100644 index 0000000000..fd424be559 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt @@ -0,0 +1,26 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute + +internal class TangemPayCardLimitSetupSuccessComponent( + appComponentContext: AppComponentContext, +) : AppComponentContext by appComponentContext, ComposableContentComponent { + + @Composable + override fun Content(modifier: Modifier) { + BackHandler(onBack = ::backToDetails) + TangemPayCardLimitSetupSuccessScreen( + modifier = modifier, + onDoneClick = ::backToDetails, + ) + } + + private fun backToDetails() { + router.popTo(route = TangemPayDetailsInnerRoute.Details) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreen.kt new file mode 100644 index 0000000000..e615ad70f8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessScreen.kt @@ -0,0 +1,102 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerH32 +import com.tangem.core.ui.components.appbar.TangemTopAppBar +import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R + +@Composable +internal fun TangemPayCardLimitSetupSuccessScreen(onDoneClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .navigationBarsPadding(), + ) { + TangemTopAppBar( + modifier = Modifier.statusBarsPadding(), + startButton = TopAppBarButtonUM.Close(onCloseClick = onDoneClick), + ) + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SuccessContent( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + PrimaryButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + text = stringResourceSafe(R.string.common_done), + onClick = onDoneClick, + ) + } + } +} + +@Composable +private fun SuccessContent(modifier: Modifier = Modifier) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_success_blue_76), + tint = Color.Unspecified, + contentDescription = null, + modifier = Modifier.size(76.dp), + ) + SpacerH32() + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_success_title), + style = TangemTheme.typography.h2, + color = TangemTheme.colors.text.primary1, + textAlign = TextAlign.Center, + ) + SpacerH12() + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.tangempay_card_page_daily_limit_success_description), + style = TangemTheme.typography.body1, + color = TangemTheme.colors.text.secondary, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + TangemThemePreview { + TangemPayCardLimitSetupSuccessScreen(onDoneClick = {}) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupUM.kt new file mode 100644 index 0000000000..e77e83b186 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupUM.kt @@ -0,0 +1,64 @@ +package com.tangem.features.tangempay.limit.setup + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class TangemPayCardLimitSetupUM( + val isInitialDataLoading: Boolean, + val amountFieldModel: AmountFieldModel, + val subtitle: TextReference, + val currencyCode: String, + val presets: ImmutableList, + val isSubmitButtonEnabled: Boolean, + val isSubmitButtonLoading: Boolean, + val onSubmitClick: () -> Unit, + val onBackClick: () -> Unit, +) { + + @Immutable + internal data class AmountFieldModel( + val value: String, + val decimals: Int, + val onValueChange: (String) -> Unit, + ) + + @Immutable + internal data class LimitPresetUM( + val label: String, + val onClick: () -> Unit, + ) + + companion object { + fun stub( + isLoading: Boolean = false, + amountFieldModel: AmountFieldModel = AmountFieldModel( + value = "5000", + decimals = 2, + onValueChange = {}, + ), + subtitle: TextReference = TextReference.Str("Set a limit from $0 to $50,000"), + currencyCode: String = "$", + presets: ImmutableList = persistentListOf( + LimitPresetUM(label = "$0", onClick = {}), + LimitPresetUM(label = "$5,000", onClick = {}), + LimitPresetUM(label = "$10,000", onClick = {}), + LimitPresetUM(label = "$25,000", onClick = {}), + ), + submitButtonEnabled: Boolean = true, + submitButtonLoading: Boolean = false, + ): TangemPayCardLimitSetupUM = TangemPayCardLimitSetupUM( + isInitialDataLoading = isLoading, + amountFieldModel = amountFieldModel, + subtitle = subtitle, + currencyCode = currencyCode, + presets = presets, + isSubmitButtonEnabled = submitButtonEnabled, + isSubmitButtonLoading = submitButtonLoading, + onSubmitClick = {}, + onBackClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 1e357c518b..2789a75e07 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -21,6 +21,8 @@ import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.hasCardWithId +import com.tangem.domain.models.account.requireCardWithId import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier @@ -87,9 +89,9 @@ internal class TangemPayCardPageModel @Inject constructor( val status = state.value if (status is PaymentAccountStatusValue.Loaded && status.source == StatusSource.ACTUAL && - status.cards.isNotEmpty() + status.hasCardWithId(params.config.cardId) ) { - val card = status.cards.first() + val card = status.requireCardWithId(params.config.cardId) val limit = card.limit?.actualCardLimit?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } val dailyLimitState = if (limit != null) { TangemPayDailyLimitBlockState.Content( @@ -97,14 +99,14 @@ internal class TangemPayCardPageModel @Inject constructor( val symbol = getJavaCurrencyByCode(status.currencyCode).symbol fiat(status.currencyCode, symbol) }, - onChangeClick = {}, // TODO v_rodionov: #[REDACTED_TASK_KEY] + onChangeClick = { router.push(TangemPayDetailsInnerRoute.LimitSetup) }, ) } else { TangemPayDailyLimitBlockState.Error } uiState.update { it.copy(dailyLimitState = dailyLimitState, settings = buildSettings(card)) } } else { - // TODO v_rodionov: #[REDACTED_TASK_KEY] show error state + uiState.update { it.copy(dailyLimitState = TangemPayDailyLimitBlockState.Error) } } } .launchIn(modelScope) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt index a559c7a3d9..f0ed2d6e98 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -61,15 +61,19 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( .onRight { cardDisplayName -> uiState.update { it.copy(isLoading = true) } modelScope.launch { - cardDetailsRepository.updateCardDisplayName(params.userWalletId, cardDisplayName) - .onRight { router.pop() } - .onLeft { - uiState.update { state -> state.copy(isLoading = false) } - showError( - titleRes = R.string.tangem_pay_card_details_unable_to_rename_card_title, - messageRes = R.string.tangempay_card_details_unable_to_rename_card_description, - ) - } + cardDetailsRepository.updateCardDisplayName( + cardId = params.config.cardId, + userWalletId = params.userWalletId, + displayName = cardDisplayName, + ).onRight { + router.pop() + }.onLeft { + uiState.update { state -> state.copy(isLoading = false) } + showError( + titleRes = R.string.tangem_pay_card_details_unable_to_rename_card_title, + messageRes = R.string.tangempay_card_details_unable_to_rename_card_description, + ) + } } } .onLeft { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt index 168ea88da5..89facef290 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt @@ -19,4 +19,10 @@ internal sealed class TangemPayDetailsInnerRoute : Route { @Serializable data object EditCardDisplayName : TangemPayDetailsInnerRoute() + + @Serializable + data object LimitSetup : TangemPayDetailsInnerRoute() + + @Serializable + data object LimitSetupSuccess : TangemPayDetailsInnerRoute() } \ No newline at end of file From 4fa59892890c99b91aa734633fce90dcf7b7d9e2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Apr 2026 10:36:30 +0200 Subject: [PATCH 111/206] Updated on 2026-08-14 --- features/swap/impl/build.gradle.kts | 4 +++ .../analytics/SwapQuotePerformanceTracker.kt | 36 +++++++++++++++++++ .../tangem/feature/swap/model/SwapModel.kt | 8 +++++ 3 files changed, 48 insertions(+) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapQuotePerformanceTracker.kt diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index 6c456d8519..f2ab5ca458 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -105,6 +105,10 @@ dependencies { implementation(deps.kotlin.serialization) implementation(deps.kotlin.immutable.collections) implementation(deps.decompose.ext.compose) + implementation(deps.firebase.perf) { + exclude(group = "com.google.firebase", module = "protolite-well-known-types") + exclude(group = "com.google.protobuf", module = "protobuf-javalite") + } /** Tangem libs */ implementation(tangemDeps.blockchain) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapQuotePerformanceTracker.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapQuotePerformanceTracker.kt new file mode 100644 index 0000000000..baec303ec2 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapQuotePerformanceTracker.kt @@ -0,0 +1,36 @@ +package com.tangem.feature.swap.analytics + +import com.google.firebase.perf.FirebasePerformance +import com.google.firebase.perf.metrics.Trace + +internal class SwapQuotePerformanceTracker { + + private var trace: Trace? = null + + fun onLoadingStarted(providersCount: Int) { + trace?.stop() + trace = FirebasePerformance.getInstance().newTrace(SWAP_QUOTES_LOADED_TRACE_NAME).apply { + putAttribute(PROVIDERS_COUNT, providersCount.toString()) + start() + } + } + + fun onLoadingFinished(hasError: Boolean) { + trace?.apply { + putAttribute(HAS_ERROR, if (hasError) "Yes" else "No") + stop() + } + trace = null + } + + fun onDestroy() { + trace?.stop() + trace = null + } + + private companion object { + const val SWAP_QUOTES_LOADED_TRACE_NAME = "Swap_quotes_loaded" + const val PROVIDERS_COUNT = "providers_count" + const val HAS_ERROR = "has_error" + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index ed470d4a60..1cc6c4cd61 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -68,6 +68,7 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.feature.swap.analytics.SwapEvents +import com.tangem.feature.swap.analytics.SwapQuotePerformanceTracker import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult @@ -192,6 +193,7 @@ internal class SwapModel @Inject constructor( private val amountDebouncer = Debouncer() private val singleTaskScheduler = SingleTaskScheduler>() + private val performanceTracker = SwapQuotePerformanceTracker() val dataStateStateFlow = MutableStateFlow(SwapProcessDataState()) var dataState @@ -295,6 +297,7 @@ internal class SwapModel @Inject constructor( override fun onDestroy() { singleTaskScheduler.cancelTask() + performanceTracker.onDestroy() super.onDestroy() } @@ -605,6 +608,7 @@ internal class SwapModel @Inject constructor( uiStateHolder = uiState, ) feeSelectorRepository.state.value = FeeSelectorUM.Loading + performanceTracker.onLoadingStarted(toProvidersList.size) } singleTaskScheduler.scheduleTask( modelScope, @@ -686,6 +690,9 @@ internal class SwapModel @Inject constructor( } }, onSuccess = { providersState -> + performanceTracker.onLoadingFinished( + hasError = providersState.values.none { it is SwapState.QuotesLoadedState }, + ) if (providersState.isNotEmpty()) { val (provider, state) = updateLoadedQuotes(providersState) setupLoadedState( @@ -715,6 +722,7 @@ internal class SwapModel @Inject constructor( }, onError = { error -> TangemLogger.e("Error when loading quotes: $error") + performanceTracker.onLoadingFinished(hasError = true) feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() } }, From 440de0f675ee9d5e939e5ba386e18065ca97dd0e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Apr 2026 11:58:12 +0100 Subject: [PATCH 112/206] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 5 +- .../com/tangem/common/routing/AppRoute.kt | 2 +- .../CreateWalletStartModel.kt | 5 +- .../CreateWalletStartModelTest.kt | 1 + features/details/impl/build.gradle.kts | 1 + .../features/details/utils/UserWalletSaver.kt | 20 ++++++-- .../v2/entry/OnboardingEntryComponent.kt | 2 +- features/onboarding-v2/impl/build.gradle.kts | 1 + .../v2/addresssync/AddressSyncComponent.kt | 4 +- .../DefaultAddressSyncComponent.kt | 7 ++- .../v2/addresssync/model/AddressSyncModel.kt | 51 ++++++++++++++++--- .../entry/impl/model/OnboardingEntryModel.kt | 5 +- .../api/OnboardingMultiWalletComponent.kt | 2 +- .../DefaultOnboardingMultiWalletComponent.kt | 19 ++++--- .../addresssync/model/AddressSyncModelTest.kt | 27 +++++++++- features/welcome/impl/build.gradle.kts | 1 + .../welcome/impl/model/WelcomeModel.kt | 17 ++++++- 17 files changed, 142 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 3d4583964f..a4d6cdc3b3 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -261,7 +261,10 @@ internal class ChildFactory @Inject constructor( is AppRoute.Onboarding.Mode.UpgradeHotWallet -> OnboardingEntryComponent.Mode.UpgradeHotWallet(mode.userWalletId) is AppRoute.Onboarding.Mode.AddressSync -> - OnboardingEntryComponent.Mode.AddressSync(mode.userWalletId) + OnboardingEntryComponent.Mode.AddressSync( + mode.userWalletId, + mode.isWalletStarted, + ) }, ), componentFactory = onboardingEntryComponentFactory, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 4bc760d66a..b8c2d6bd0a 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -334,7 +334,7 @@ sealed class AppRoute(val path: String) : Route { data object RecreateWalletTwin : Mode() // reset twins data object ContinueFinalize : Mode() // continue finalize process (unfinished backup dialog) data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() // upgrade hot wallet - data class AddressSync(val userWalletId: UserWalletId) : Mode() + data class AddressSync(val userWalletId: UserWalletId, val isWalletStarted: Boolean) : Mode() } } diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 91f69a736d..9a2539c546 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -239,7 +239,10 @@ internal class CreateWalletStartModel @Inject constructor( val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { AppRoute.Onboarding( scanResponse = scanResponse, - mode = AppRoute.Onboarding.Mode.AddressSync(userWalletId = userWallet.walletId), + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = userWallet.walletId, + isWalletStarted = false, + ), ) } else { AppRoute.Wallet diff --git a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt index 54b19b9b4f..8b39b42163 100644 --- a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt +++ b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt @@ -538,6 +538,7 @@ internal class CreateWalletStartModelTest { scanResponse = testScanResponse, mode = AppRoute.Onboarding.Mode.AddressSync( userWalletId = testUserWalletId, + isWalletStarted = false, ), ) ), diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index e2f9062600..d188416da8 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(projects.features.tester.api) implementation(projects.features.createWalletSelection.api) implementation(projects.features.tangempay.details.api) + implementation(projects.features.onboardingV2.api) /* Project - Core */ implementation(projects.core.decompose) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt index 977bb4042c..59cedb1d8c 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/UserWalletSaver.kt @@ -24,6 +24,7 @@ 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.details.impl.R +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -38,6 +39,7 @@ internal class UserWalletSaver @Inject constructor( private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, private val messageSender: UiMessageSender, private val router: Router, + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, ) { suspend fun scanAndSaveUserWallet(scope: CoroutineScope) { @@ -55,7 +57,7 @@ internal class UserWalletSaver @Inject constructor( block = { scanResponse ?: return val userWallet = createUserWallet(scanResponse) - saveWallet(userWallet) + saveWallet(userWallet, scanResponse) }, recover = { val message = it.message @@ -69,7 +71,7 @@ internal class UserWalletSaver @Inject constructor( ) } - private suspend fun Raise.saveWallet(userWallet: UserWallet) { + private suspend fun Raise.saveWallet(userWallet: UserWallet, scanResponse: ScanResponse) { fold( block = { saveWalletUseCase( @@ -92,7 +94,19 @@ internal class UserWalletSaver @Inject constructor( } }, transform = { - router.popTo() + if (onboardingV2FeatureToggles.isAddressSyncEnabled) { + router.push( + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = userWallet.walletId, + isWalletStarted = true, + ), + ), + ) + } else { + router.popTo() + } }, ) } diff --git a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt index a8314aff42..8407f8fe31 100644 --- a/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt +++ b/features/onboarding-v2/api/src/main/kotlin/com/tangem/features/onboarding/v2/entry/OnboardingEntryComponent.kt @@ -19,7 +19,7 @@ interface OnboardingEntryComponent : ComposableContentComponent { data object RecreateWalletTwin : Mode() data object ContinueFinalize : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() - data class AddressSync(val userWalletId: UserWalletId) : Mode() + data class AddressSync(val userWalletId: UserWalletId, val isWalletStarted: Boolean) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/build.gradle.kts b/features/onboarding-v2/impl/build.gradle.kts index a2fadc93e1..889540badf 100644 --- a/features/onboarding-v2/impl/build.gradle.kts +++ b/features/onboarding-v2/impl/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation(projects.domain.tokens.models) implementation(projects.domain.onramp) implementation(projects.domain.transaction) + implementation(projects.domain.staking) /** Tangem libraries */ implementation(tangemDeps.hot.core) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt index d951a3e058..5b5db3568c 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/AddressSyncComponent.kt @@ -2,4 +2,6 @@ package com.tangem.features.onboarding.v2.addresssync import com.tangem.core.ui.decompose.ComposableContentComponent -interface AddressSyncComponent : ComposableContentComponent \ No newline at end of file +interface AddressSyncComponent : ComposableContentComponent { + data class Params(val isWalletStarted: Boolean) +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index 2cca709ec2..cd987407fe 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -32,6 +32,7 @@ import com.tangem.features.pushnotifications.api.PushNotificationsParams internal class DefaultAddressSyncComponent( appComponentContext: AppComponentContext, params: MultiWalletChildParams, + private val addressSyncParams: AddressSyncComponent.Params, private val askBiometryComponentFactory: AskBiometryComponent.Factory, private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, ) : AppComponentContext by appComponentContext, AddressSyncComponent { @@ -83,7 +84,11 @@ internal class DefaultAddressSyncComponent( }, ) AddressSyncState.Exit -> LaunchedEffect(Unit) { - router.replaceAll(AppRoute.Wallet) + if (addressSyncParams.isWalletStarted) { + router.popTo(AppRoute.Wallet) + } else { + router.replaceAll(AppRoute.Wallet) + } } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt index 56a5c838ae..1471de14fb 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -8,18 +8,24 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.supplier.MultiAccountListSupplier +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.domain.staking.StakingIdFactory +import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.MultiWalletInnerNavigationState import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.* +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import javax.inject.Inject @@ -34,6 +40,9 @@ internal class AddressSyncModel @Inject constructor( private val multiWalletAccountListFetcher: MultiWalletAccountListFetcher, private val multiAccountListSupplier: MultiAccountListSupplier, private val derivePublicKeysUseCase: DerivePublicKeysUseCase, + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher, + private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher, + private val stakingIdFactory: StakingIdFactory, paramsContainer: ParamsContainer, ) : Model() { @@ -46,12 +55,10 @@ internal class AddressSyncModel @Inject constructor( ) init { - params.innerNavigation.update { innerNavigationState -> - innerNavigationState.copy( - stackSize = AddressSyncStep.ASK_BIOMETRY.pageNumber, - stackMaxSize = ADDRESS_SYNC_MAX_STEPS, - ) - } + params.innerNavigation.value = MultiWalletInnerNavigationState( + stackSize = AddressSyncStep.ASK_BIOMETRY.pageNumber, + stackMaxSize = ADDRESS_SYNC_MAX_STEPS, + ) modelScope.launch { trySkippingScreen(AddressSyncStep.ASK_BIOMETRY) } @@ -152,11 +159,41 @@ internal class AddressSyncModel @Inject constructor( ) TangemLogger.e("Failed to derive public keys", throwable) }, - ifRight = { state.value = AddressSyncState.Exit }, + ifRight = { + listOf( + launch { fetchNetworks(cryptoCurrencies) }, + launch { fetchStaking(cryptoCurrencies) }, + ).joinAll() + state.value = AddressSyncState.Exit + }, ) } } + private suspend fun fetchNetworks(cryptoCurrencies: List) { + multiNetworkStatusFetcher.invoke( + MultiNetworkStatusFetcher.Params( + userWalletId = walletId, + networks = cryptoCurrencies.map(CryptoCurrency::network).toSet(), + ), + ) + .onLeft { TangemLogger.e("Unable to fetch networks: $it") } + } + + private suspend fun fetchStaking(cryptoCurrencies: List) { + val stakingIds = cryptoCurrencies.mapNotNullTo(hashSetOf()) { + stakingIdFactory.create(userWalletId = walletId, cryptoCurrency = it).getOrNull() + } + + multiStakingBalanceFetcher( + params = MultiStakingBalanceFetcher.Params( + userWalletId = walletId, + stakingIds = stakingIds, + ), + ) + .onLeft { TangemLogger.e("Unable to fetch yield balances: $it") } + } + private companion object { const val ADDRESS_SYNC_MAX_STEPS = 3 } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index aab619c0e9..baefd2245f 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -82,7 +82,10 @@ internal class OnboardingEntryModel @Inject constructor( is Mode.UpgradeHotWallet -> OnboardingMultiWalletComponent.Mode.UpgradeHotWallet( userWalletId = mode.userWalletId, ) - is Mode.AddressSync -> OnboardingMultiWalletComponent.Mode.AddressSync(mode.userWalletId) + is Mode.AddressSync -> OnboardingMultiWalletComponent.Mode.AddressSync( + mode.userWalletId, + mode.isWalletStarted, + ) else -> error("Incorrect onboarding type") } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt index ed4280ad63..a64e717f3b 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/api/OnboardingMultiWalletComponent.kt @@ -24,7 +24,7 @@ interface OnboardingMultiWalletComponent : ComposableContentComponent, InnerNavi data object AddBackup : Mode() data object ContinueFinalize : Mode() data class UpgradeHotWallet(val userWalletId: UserWalletId) : Mode() - data class AddressSync(val userWalletId: UserWalletId) : Mode() + data class AddressSync(val userWalletId: UserWalletId, val isWalletStarted: Boolean) : Mode() } interface Factory : ComponentFactory diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index 4f7588e705..3d96a24e74 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -26,6 +26,7 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigation import com.tangem.core.decompose.navigation.inner.InnerNavigationState 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 @@ -192,12 +193,18 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor onBack = { model.onBack() }, onEvent = ::handleFinalizeComponentEvent, ) - AddressSync -> DefaultAddressSyncComponent( - appComponentContext = childContext, - params = childParams, - askBiometryComponentFactory = askBiometryComponentFactory, - pushNotificationsComponentFactory = pushNotificationsComponentFactory, - ) + AddressSync -> { + val mode = params.mode as OnboardingMultiWalletComponent.Mode.AddressSync + DefaultAddressSyncComponent( + appComponentContext = childContext, + params = childParams, + addressSyncParams = AddressSyncComponent.Params( + isWalletStarted = mode.isWalletStarted, + ), + askBiometryComponentFactory = askBiometryComponentFactory, + pushNotificationsComponentFactory = pushNotificationsComponentFactory, + ) + } Done -> error("Unexpected Done state") } } diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt index 484b8a2932..c3d4a2df19 100644 --- a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -8,9 +8,12 @@ import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.settings.CanUseBiometryUseCase import com.tangem.domain.settings.ShouldAskPermissionUseCase import com.tangem.domain.settings.ShouldShowAskBiometryUseCase +import com.tangem.domain.staking.StakingIdFactory +import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.tokens.MultiWalletAccountListFetcher import com.tangem.domain.wallets.usecase.DerivePublicKeysUseCase import com.tangem.features.onboarding.v2.TitleProvider @@ -42,6 +45,9 @@ internal class AddressSyncModelTest { private val multiAccountListSupplier: MultiAccountListSupplier = mockk() private val derivePublicKeysUseCase: DerivePublicKeysUseCase = mockk() private val paramsContainer: ParamsContainer = mockk() + private val multiNetworkStatusFetcher: MultiNetworkStatusFetcher = mockk() + private val multiStakingBalanceFetcher: MultiStakingBalanceFetcher = mockk() + private val stakingIdFactory: StakingIdFactory = mockk() private val testInnerNavigation = MutableStateFlow( value = MultiWalletInnerNavigationState( stackSize = 0, @@ -54,7 +60,10 @@ internal class AddressSyncModelTest { every { innerNavigation } returns testInnerNavigation every { parentParams } returns mockk { every { titleProvider } returns this@AddressSyncModelTest.titleProvider - every { mode } returns OnboardingMultiWalletComponent.Mode.AddressSync(walletId) + every { mode } returns OnboardingMultiWalletComponent.Mode.AddressSync( + userWalletId = walletId, + isWalletStarted = false, + ) } } @@ -236,7 +245,11 @@ internal class AddressSyncModelTest { @Test fun `GIVEN success state WHEN Sync THEN state becomes Exit`() = runTest { - val currencies = listOf(mockk(), mockk(), mockk()) + val currencies = listOf( + mockk { every { network } returns mockk() }, + mockk { every { network } returns mockk() }, + mockk { every { network } returns mockk() }, + ) every { multiAccountListSupplier() } returns flowOf( listOf( AccountList.empty( @@ -246,6 +259,11 @@ internal class AddressSyncModelTest { ), ) coEvery { derivePublicKeysUseCase(walletId, currencies) } returns Either.Right(Unit) + coEvery { multiNetworkStatusFetcher.invoke(any()) } returns Either.Right(Unit) + coEvery { + stakingIdFactory.create(userWalletId = walletId, cryptoCurrency = any()) + } returns Either.Right(mockk()) + coEvery { multiStakingBalanceFetcher(any()) } returns Either.Right(Unit) val model = createModel(this) advanceUntilIdle() @@ -254,6 +272,8 @@ internal class AddressSyncModelTest { advanceUntilIdle() coVerify { derivePublicKeysUseCase(walletId, currencies) } + coVerify { multiNetworkStatusFetcher.invoke(any()) } + coVerify { multiStakingBalanceFetcher(any()) } Assertions.assertEquals(AddressSyncState.Exit, model.state.value) } @@ -309,6 +329,9 @@ internal class AddressSyncModelTest { multiWalletAccountListFetcher = multiWalletAccountListFetcher, multiAccountListSupplier = multiAccountListSupplier, derivePublicKeysUseCase = derivePublicKeysUseCase, + multiNetworkStatusFetcher = multiNetworkStatusFetcher, + multiStakingBalanceFetcher = multiStakingBalanceFetcher, + stakingIdFactory = stakingIdFactory, paramsContainer = paramsContainer, ) } diff --git a/features/welcome/impl/build.gradle.kts b/features/welcome/impl/build.gradle.kts index 11cc45a04f..bc703ce7b6 100644 --- a/features/welcome/impl/build.gradle.kts +++ b/features/welcome/impl/build.gradle.kts @@ -14,6 +14,7 @@ android { dependencies { implementation(projects.features.welcome.api) implementation(projects.features.wallet.api) + implementation(projects.features.onboardingV2.api) /** Core */ implementation(projects.core.configToggles) diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt index 5ecd009e00..c66d770d6d 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt @@ -28,6 +28,7 @@ import com.tangem.domain.wallets.builder.ColdUserWalletBuilder import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.usecase.NonBiometricUnlockWalletUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.features.welcome.impl.ui.state.WelcomeUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -54,9 +55,10 @@ internal class WelcomeModel @Inject constructor( private val trackingContextProxy: TrackingContextProxy, private val analyticsEventHandler: AnalyticsEventHandler, userWalletsFetcherFactory: UserWalletsFetcher.Factory, - private val hotWalletRestrictionManager: HotWalletRestrictionManager, + hotWalletRestrictionManager: HotWalletRestrictionManager, private val scanCardProcessor: ScanCardProcessor, private val messageSender: UiMessageSender, + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, ) : Model() { val uiState: StateFlow @@ -215,7 +217,18 @@ internal class WelcomeModel @Inject constructor( } } .onRight { - router.replaceAll(AppRoute.Wallet) + val route = if (onboardingV2FeatureToggles.isAddressSyncEnabled) { + AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = userWallet.walletId, + isWalletStarted = false, + ), + ) + } else { + AppRoute.Wallet + } + router.replaceAll(route) } }, onCancel = {}, From 2d0bc9d9e6cfe012512487694d53c43d0580bc82 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 24 Apr 2026 14:00:32 +0400 Subject: [PATCH 113/206] Updated on 2026-08-14 --- .../com/tangem/tap/di/data/CardSdkModule.kt | 24 +++++++ .../tangem/tap/di/domain/CardDomainModule.kt | 3 +- .../DefaultDeleteSavedAccessCodesUseCase.kt | 22 ------ .../DefaultUserWalletsListRepository.kt | 6 +- .../ui/resetcard/model/ResetCardModel.kt | 3 + domain/card/build.gradle.kts | 10 ++- .../card/DeleteSavedAccessCodesUseCase.kt | 29 +++++++- .../card/DeleteSavedAccessCodesUseCaseTest.kt | 69 +++++++++++++++++++ .../wallets/usecase/DeleteWalletUseCase.kt | 9 ++- gradle/dependencies.toml | 1 + libs/tangem-sdk-api/build.gradle.kts | 7 -- .../com/tangem/sdk/api/di/CardSdkModule.kt | 32 --------- 12 files changed, 141 insertions(+), 74 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/domain/card/DefaultDeleteSavedAccessCodesUseCase.kt create mode 100644 domain/card/src/test/java/com/tangem/domain/card/DeleteSavedAccessCodesUseCaseTest.kt delete mode 100644 libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt diff --git a/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt b/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt index 118bcb16a7..496271ef06 100644 --- a/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/CardSdkModule.kt @@ -1,12 +1,18 @@ package com.tangem.tap.di.data +import android.content.Context import com.tangem.data.card.sdk.CardSdkOwner import com.tangem.data.card.sdk.CardSdkProvider +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.operations.attestation.CardArtworksProvider import com.tangem.tap.data.DefaultCardSdkProvider import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.io.File import javax.inject.Singleton @Module @@ -20,4 +26,22 @@ internal interface CardSdkModule { @Binds @Singleton fun providerCardSdkLifecycleObserver(defaultCardSdkProvider: DefaultCardSdkProvider): CardSdkOwner + + companion object { + + @Provides + @Singleton + fun provideCardArtworksProvider( + sdkRepository: CardSdkConfigRepository, + @ApplicationContext context: Context, + ): CardArtworksProvider { + return CardArtworksProvider( + tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl }, + artworksDirectory = File( + context.getExternalFilesDir(null) ?: context.filesDir, + "card_artworks", + ).apply { mkdirs() }, + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt index 7f99ec03db..45b884766c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt @@ -11,7 +11,6 @@ import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.wallets.derivations.DerivationsRepository import com.tangem.domain.wallets.usecase.* import com.tangem.sdk.api.TangemSdkManager -import com.tangem.tap.domain.card.DefaultDeleteSavedAccessCodesUseCase import com.tangem.tap.domain.card.DefaultResetCardUseCase import dagger.Module import dagger.Provides @@ -56,7 +55,7 @@ internal object CardDomainModule { @Provides @Singleton fun provideDeleteSavedAccessCodesUseCase(tangemSdkManager: TangemSdkManager): DeleteSavedAccessCodesUseCase { - return DefaultDeleteSavedAccessCodesUseCase(tangemSdkManager) + return DeleteSavedAccessCodesUseCase(tangemSdkManager) } @Provides diff --git a/app/src/main/java/com/tangem/tap/domain/card/DefaultDeleteSavedAccessCodesUseCase.kt b/app/src/main/java/com/tangem/tap/domain/card/DefaultDeleteSavedAccessCodesUseCase.kt deleted file mode 100644 index 9a89db05b8..0000000000 --- a/app/src/main/java/com/tangem/tap/domain/card/DefaultDeleteSavedAccessCodesUseCase.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.tap.domain.card - -import arrow.core.Either -import arrow.core.left -import arrow.core.right -import com.tangem.common.doOnFailure -import com.tangem.common.doOnSuccess -import com.tangem.domain.card.DeleteSavedAccessCodesUseCase -import com.tangem.sdk.api.TangemSdkManager - -internal class DefaultDeleteSavedAccessCodesUseCase( - private val tangemSdkManager: TangemSdkManager, -) : DeleteSavedAccessCodesUseCase { - - override suspend fun invoke(cardId: String): Either { - tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) - .doOnFailure { return it.left() } - .doOnSuccess { return Unit.right() } - - return Unit.right() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index c464e78a94..3d7b8d17ec 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -36,9 +36,9 @@ import com.tangem.tap.domain.userWalletList.utils.* import com.tangem.utils.Provider import com.tangem.utils.ProviderSuspend import com.tangem.utils.coroutines.runSuspendCatching -import dagger.Lazy import com.tangem.utils.extensions.addOrReplace import com.tangem.utils.extensions.indexOfFirstOrNull +import dagger.Lazy import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -96,8 +96,8 @@ internal class DefaultUserWalletsListRepository( ?: loadedWallets.firstOrNull()?.also { selectedUserWalletRepository.set(it.walletId) } - userWallets.value = loadedWallets setSelectedUserWallet(initialSelection) + userWallets.value = loadedWallets } } } @@ -235,7 +235,6 @@ internal class DefaultUserWalletsListRepository( ) } - userWallets.value = updatedWallets if (currentSelected != null) { if (newSelected == null) { onAllWalletsDeleted() @@ -243,6 +242,7 @@ internal class DefaultUserWalletsListRepository( selectedUserWalletRepository.set(newSelected?.walletId) setSelectedUserWallet(newSelected) } + userWallets.value = updatedWallets } @Suppress("CyclomaticComplexMethod", "LongMethod") diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt index d96d1fcce0..2f301e8206 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/resetcard/model/ResetCardModel.kt @@ -197,6 +197,9 @@ internal class ResetCardModel @Inject constructor( checkRemainingBackupCards() } + .onLeft { + TangemLogger.e("Failed to reset card: $it") + } } } diff --git a/domain/card/build.gradle.kts b/domain/card/build.gradle.kts index 44e17e2ce6..abd1254e93 100644 --- a/domain/card/build.gradle.kts +++ b/domain/card/build.gradle.kts @@ -8,6 +8,10 @@ android { namespace = "com.tangem.domain.card" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { implementation(projects.core.analytics.models) implementation(projects.core.error) @@ -25,6 +29,7 @@ dependencies { implementation(projects.domain.visa.models) implementation(projects.core.utils) + implementation(projects.libs.tangemSdkApi) implementation(tangemDeps.card.core) implementation(tangemDeps.blockchain) { @@ -32,9 +37,8 @@ dependencies { } /** Testing libraries */ - testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) + testRuntimeOnly(deps.test.junit5.vintage.engine) testImplementation(projects.common.test) + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/DeleteSavedAccessCodesUseCase.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/DeleteSavedAccessCodesUseCase.kt index dc118e8f15..32902756ba 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/DeleteSavedAccessCodesUseCase.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/DeleteSavedAccessCodesUseCase.kt @@ -1,8 +1,33 @@ package com.tangem.domain.card import arrow.core.Either +import arrow.core.raise.either +import com.tangem.common.doOnFailure +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.logging.TangemLogger -interface DeleteSavedAccessCodesUseCase { +/** + * Removes saved user codes (access code and/or passcode) for a physical Tangem card + * from the device's secure storage. + * + * Typically invoked after a successful factory reset of the card, so that stale codes + * for an already-wiped card are not left on the device. + * + * @property tangemSdkManager Card SDK wrapper that performs the code removal operation + */ +class DeleteSavedAccessCodesUseCase( + private val tangemSdkManager: TangemSdkManager, +) { - suspend operator fun invoke(cardId: String): Either + /** + * @param cardId identifier of the card whose saved codes must be removed + * @return [Unit] on success; a Card SDK error (as [Throwable]) if removal failed + */ + suspend operator fun invoke(cardId: String): Either = either { + tangemSdkManager.deleteSavedUserCodes(cardsIds = setOf(cardId)) + .doOnFailure { error -> + TangemLogger.e("Failed to delete saved access codes for card with id: $cardId", error) + raise(error) + } + } } \ No newline at end of file diff --git a/domain/card/src/test/java/com/tangem/domain/card/DeleteSavedAccessCodesUseCaseTest.kt b/domain/card/src/test/java/com/tangem/domain/card/DeleteSavedAccessCodesUseCaseTest.kt new file mode 100644 index 0000000000..c5c0d63d9a --- /dev/null +++ b/domain/card/src/test/java/com/tangem/domain/card/DeleteSavedAccessCodesUseCaseTest.kt @@ -0,0 +1,69 @@ +package com.tangem.domain.card + +import com.tangem.common.CompletionResult +import com.tangem.common.core.TangemSdkError +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.test.core.assertEitherLeft +import com.tangem.test.core.assertEitherRight +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DeleteSavedAccessCodesUseCaseTest { + + private val tangemSdkManager = mockk() + + private lateinit var useCase: DeleteSavedAccessCodesUseCase + + @BeforeEach + fun setup() { + clearMocks(tangemSdkManager) + useCase = DeleteSavedAccessCodesUseCase(tangemSdkManager = tangemSdkManager) + } + + @Test + fun `returns Right Unit when sdk deletes codes successfully`() = runTest { + // Arrange + val cardId = "AA00000000000001" + coEvery { tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) } returns CompletionResult.Success(Unit) + + // Act + val actual = useCase(cardId = cardId) + + // Assert + assertEitherRight(actual) + } + + @Test + fun `returns Left with sdk error when sdk fails`() = runTest { + // Arrange + val cardId = "AA00000000000002" + val sdkError = TangemSdkError.ExceptionError(RuntimeException("boom")) + coEvery { tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) } returns CompletionResult.Failure(sdkError) + + // Act + val actual = useCase(cardId = cardId) + + // Assert + assertEitherLeft(actual, sdkError) + } + + @Test + fun `passes exactly the given cardId as a singleton set to sdk`() = runTest { + // Arrange + val cardId = "AA00000000000003" + coEvery { tangemSdkManager.deleteSavedUserCodes(any()) } returns CompletionResult.Success(Unit) + + // Act + useCase(cardId = cardId) + + // Assert + coVerify(exactly = 1) { tangemSdkManager.deleteSavedUserCodes(setOf(cardId)) } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt index 4037df2361..431d52cb5f 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/DeleteWalletUseCase.kt @@ -4,6 +4,7 @@ import arrow.core.Either import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.DeleteWalletError import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.logging.TangemLogger /** * Use case for deleting user wallet @@ -24,8 +25,10 @@ class DeleteWalletUseCase( * @return [Either] with [com.tangem.domain.common.wallets.error.DeleteWalletError] or [Boolean] which indicates that there are still saved wallets. * */ suspend operator fun invoke(userWalletId: UserWalletId): Either { - return userWalletsListRepository.delete(userWalletIds = listOf(userWalletId)).map { - userWalletsListRepository.selectedUserWallet.value != null - } + return userWalletsListRepository.delete(userWalletIds = listOf(userWalletId)) + .map { userWalletsListRepository.selectedUserWallet.value != null } + .onLeft { + TangemLogger.e("Failed to delete wallet with id ${userWalletId.value}: $it") + } } } \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index cb69868e03..5bf1853472 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -228,6 +228,7 @@ test-espresso-intents = { module = "androidx.test.espresso:espresso-intents", ve test-junit = { module = "junit:junit", version.ref = "junit" } test-junit5 = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" } test-junit5-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } +test-junit5-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } test-junit-android = { module = "androidx.test.ext:junit", version.ref = "junitAndroidExt" } test-truth = { module = "com.google.truth:truth", version.ref = "truth" } test-mockk = { module = "io.mockk:mockk", version.ref = "mockk" } diff --git a/libs/tangem-sdk-api/build.gradle.kts b/libs/tangem-sdk-api/build.gradle.kts index bf167d3a08..1a6d4da84d 100644 --- a/libs/tangem-sdk-api/build.gradle.kts +++ b/libs/tangem-sdk-api/build.gradle.kts @@ -2,7 +2,6 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) alias(deps.plugins.kotlin.kapt) - alias(deps.plugins.kotlin.serialization) alias(deps.plugins.hilt.android) id("configuration") } @@ -12,11 +11,7 @@ android { } dependencies { - implementation(projects.common) implementation(projects.domain.models) - implementation(projects.domain.card) - implementation(projects.domain.legacy) - implementation(projects.domain.wallets.models) implementation(projects.domain.visa.models) implementation(projects.core.configToggles) @@ -28,8 +23,6 @@ dependencies { exclude(module = "joda-time") } - /** Other libraries */ - /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt deleted file mode 100644 index f27fd2b825..0000000000 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/di/CardSdkModule.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tangem.sdk.api.di - -import android.content.Context -import com.tangem.domain.card.repository.CardSdkConfigRepository -import com.tangem.operations.attestation.CardArtworksProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import java.io.File -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object CardSdkModule { - - @Provides - @Singleton - fun provideCardArtworksProvider( - sdkRepository: CardSdkConfigRepository, - @ApplicationContext context: Context, - ): CardArtworksProvider { - return CardArtworksProvider( - tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl }, - artworksDirectory = File( - context.getExternalFilesDir(null) ?: context.filesDir, - "card_artworks", - ).apply { mkdirs() }, - ) - } -} \ No newline at end of file From ac9a3d15c3beabdc61c968dd56dd5723e0b6fb4e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Apr 2026 13:44:10 +0200 Subject: [PATCH 114/206] Updated on 2026-08-14 --- .../ui/markets/action/CryptoCurrencyData.kt | 3 + .../common/ui/markets/action/QuickActionUM.kt | 124 ++++++-- .../markets/action/QuickActionsConverter.kt | 93 ++++-- .../common/ui/account/PortfolioSelectRow.kt | 85 ++++- .../common/ui/addtoken/AddTokenContent.kt | 2 +- .../common/ui/addtoken/AddTokenContentV2.kt | 213 +++++++++++++ core/res/src/main/res/values/strings.xml | 3 + .../core/ui/components/account/AccountIcon.kt | 7 +- .../token/internal/TokenFiatAmount.kt | 41 ++- .../components/token/state/TokenItemState.kt | 5 + .../main/res/drawable/ic_credit_card_20.xml | 9 + .../main/res/drawable/ic_select_choice_20.xml | 9 + .../impl/addtoportfolio/AddTokenComponent.kt | 19 +- .../addtoportfolio/TokenActionsComponent.kt | 17 +- .../model/AddToPortfolioModel.kt | 6 +- .../addtoportfolio/model/TokenActionsModel.kt | 39 ++- .../model/TokenActionsUiBuilder.kt | 185 ++++++++++- .../addtoportfolio/ui/ChooseNetworkContent.kt | 170 +++++++++- .../addtoportfolio/ui/TokenActionsContent.kt | 8 +- .../ui/TokenActionsContentV2.kt | 292 ++++++++++++++++++ .../addtoportfolio/ui/state/TokenActionsUM.kt | 3 + .../DefaultPortfolioSelectorComponent.kt | 17 +- .../ui/PortfolioSelectorBS.kt | 37 ++- .../ui/PortfolioSelectorContentV2.kt | 228 ++++++++++++++ .../impl/model/MarketsPortfolioDelegate.kt | 5 + .../impl/model/PortfolioTokenUMConverter.kt | 13 +- .../impl/ui/PortfolioQuickActions.kt | 16 +- .../preview/PreviewMyPortfolioUMProvider.kt | 6 +- 28 files changed, 1528 insertions(+), 127 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt create mode 100644 core/ui/src/main/res/drawable/ic_credit_card_20.xml create mode 100644 core/ui/src/main/res/drawable/ic_select_choice_20.xml create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContentV2.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt index 34ecb8e2f3..d4133cc3ef 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/CryptoCurrencyData.kt @@ -1,5 +1,6 @@ package com.tangem.common.ui.markets.action +import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.model.TokenActionsState @@ -8,4 +9,6 @@ data class CryptoCurrencyData( val userWallet: UserWallet, val status: CryptoCurrencyStatus, val actions: List, + val isAccountMode: Boolean, + val account: AccountStatus.CryptoPortfolio, ) \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt index c2565c295b..88b65e767a 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionUM.kt @@ -9,43 +9,103 @@ import com.tangem.core.ui.extensions.wrappedList @Immutable sealed class QuickActionUM( - val title: TextReference, - val description: TextReference, - @DrawableRes val icon: Int, - val isLongClickAvailable: Boolean = false, + open val title: TextReference, + open val description: TextReference, + @param:DrawableRes open val icon: Int, + open val isLongClickAvailable: Boolean = false, ) { - data object Buy : QuickActionUM( - title = resourceReference(R.string.common_buy), - description = resourceReference(R.string.buy_token_description), - icon = R.drawable.ic_plus_24, - ) - data class Exchange( - val shouldShowBadge: Boolean, + sealed class V1( + override val title: TextReference, + override val description: TextReference, + @param:DrawableRes override val icon: Int, + override val isLongClickAvailable: Boolean = false, ) : QuickActionUM( - title = resourceReference(R.string.common_exchange), - description = resourceReference(R.string.exсhange_token_description), - icon = R.drawable.ic_exchange_vertical_24, - ) + title = title, + description = description, + icon = icon, + isLongClickAvailable = isLongClickAvailable, + ) { + data object Buy : V1( + title = resourceReference(R.string.common_buy), + description = resourceReference(R.string.buy_token_description), + icon = R.drawable.ic_plus_24, + ) - data object Receive : QuickActionUM( - title = resourceReference(R.string.common_receive), - description = resourceReference(R.string.receive_token_description), - icon = R.drawable.ic_arrow_down_24, - isLongClickAvailable = true, - ) + data class Exchange( + val shouldShowBadge: Boolean, + ) : V1( + title = resourceReference(R.string.common_exchange), + description = resourceReference(R.string.exсhange_token_description), + icon = R.drawable.ic_exchange_vertical_24, + ) - data object Stake : QuickActionUM( - title = resourceReference(R.string.common_stake), - description = resourceReference(R.string.stake_token_description), - icon = R.drawable.ic_staking_24, - ) + data object Receive : V1( + title = resourceReference(R.string.common_receive), + description = resourceReference(R.string.receive_token_description), + icon = R.drawable.ic_arrow_down_24, + isLongClickAvailable = true, + ) - data class YieldMode( - private val apy: String, + data object Stake : V1( + title = resourceReference(R.string.common_stake), + description = resourceReference(R.string.stake_token_description), + icon = R.drawable.ic_staking_24, + ) + + data class YieldMode( + private val apy: String, + ) : V1( + title = resourceReference(R.string.common_yield_mode), + description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), + icon = R.drawable.ic_analytics_up_mini_24, + ) + } + + sealed class V2( + override val title: TextReference, + override val description: TextReference, + @param:DrawableRes override val icon: Int, + override val isLongClickAvailable: Boolean = false, ) : QuickActionUM( - title = resourceReference(R.string.common_yield_mode), - description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), - icon = R.drawable.ic_analytics_up_mini_24, - ) + title = title, + description = description, + icon = icon, + isLongClickAvailable = isLongClickAvailable, + ) { + data object Buy : V2( + title = resourceReference(R.string.common_buy), + description = resourceReference(R.string.quick_action_buy_description), + icon = R.drawable.ic_credit_card_20, + ) + + data class Exchange( + val shouldShowBadge: Boolean, + ) : V2( + title = resourceReference(R.string.common_exchange), + description = resourceReference(R.string.quick_action_swap_description), + icon = R.drawable.ic_exchange_mini_24, + ) + + data object Receive : V2( + title = resourceReference(R.string.common_receive), + description = resourceReference(R.string.quick_action_receive_description), + icon = R.drawable.ic_qrcode_new_24, + isLongClickAvailable = true, + ) + + data object Stake : V2( + title = resourceReference(R.string.common_stake), + description = resourceReference(R.string.stake_token_description), + icon = R.drawable.ic_staking_24, + ) + + data class YieldMode( + private val apy: String, + ) : V2( + title = resourceReference(R.string.common_yield_mode), + description = resourceReference(R.string.yield_module_main_screen_promo_banner_message, wrappedList(apy)), + icon = R.drawable.ic_analytics_up_mini_24, + ) + } } \ No newline at end of file diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt index f0112cab38..47f53be359 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/QuickActionsConverter.kt @@ -2,39 +2,64 @@ package com.tangem.common.ui.markets.action import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.tokens.model.TokenActionsState +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList object QuickActionsConverter { - fun quickActions(cryptoData: CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler): QuickActions { + fun quickActions( + cryptoData: CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + isRedesignEnabled: Boolean, + ): QuickActions { return QuickActions( - actions = toQuickActions(cryptoData.actions), + actions = toQuickActions(cryptoData.actions, isRedesignEnabled), onQuickActionClick = { quickActionUM -> when (quickActionUM) { - QuickActionUM.Buy -> tokenActionsHandler.handle( + QuickActionUM.V1.Buy -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Buy, cryptoCurrencyData = cryptoData, ) - is QuickActionUM.Exchange -> tokenActionsHandler.handle( + is QuickActionUM.V1.Exchange -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Exchange, cryptoCurrencyData = cryptoData, ) - QuickActionUM.Receive -> tokenActionsHandler.handle( + QuickActionUM.V1.Receive -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Receive, cryptoCurrencyData = cryptoData, ) - QuickActionUM.Stake -> tokenActionsHandler.handle( + QuickActionUM.V1.Stake -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.Stake, cryptoCurrencyData = cryptoData, ) - is QuickActionUM.YieldMode -> tokenActionsHandler.handle( + is QuickActionUM.V1.YieldMode -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.YieldMode, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.V2.Buy -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Buy, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.V2.Exchange -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Exchange, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.V2.Receive -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Receive, + cryptoCurrencyData = cryptoData, + ) + QuickActionUM.V2.Stake -> tokenActionsHandler.handle( + action = TokenActionsBSContentUM.Action.Stake, + cryptoCurrencyData = cryptoData, + ) + is QuickActionUM.V2.YieldMode -> tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.YieldMode, cryptoCurrencyData = cryptoData, ) } }, onQuickActionLongClick = { actionUM -> - if (actionUM == QuickActionUM.Receive) { + if (actionUM == QuickActionUM.V1.Receive || actionUM == QuickActionUM.V2.Receive) { tokenActionsHandler.handle( action = TokenActionsBSContentUM.Action.CopyAddress, cryptoCurrencyData = cryptoData, @@ -44,18 +69,44 @@ object QuickActionsConverter { ) } - fun toQuickActions(actions: List) = buildList { - actions.forEach { action -> - if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { - when (action) { - is TokenActionsState.ActionState.Buy -> QuickActionUM.Buy - is TokenActionsState.ActionState.Swap -> QuickActionUM.Exchange(action.shouldShowBadge) - is TokenActionsState.ActionState.Receive -> QuickActionUM.Receive - is TokenActionsState.ActionState.Stake -> QuickActionUM.Stake - is TokenActionsState.ActionState.YieldMode -> QuickActionUM.YieldMode(action.apy) - else -> null - }?.let(::add) - } + fun toQuickActions(actions: List, isRedesignEnabled: Boolean) = + if (isRedesignEnabled) { + redesignedQuickActions(actions) + } else { + legacyQuickActions(actions) } - }.toImmutableList() + + private fun redesignedQuickActions(actions: List): ImmutableList { + return buildList { + actions.forEach { action -> + if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { + when (action) { + is TokenActionsState.ActionState.Buy -> QuickActionUM.V2.Buy + is TokenActionsState.ActionState.Swap -> QuickActionUM.V2.Exchange(action.shouldShowBadge) + is TokenActionsState.ActionState.Receive -> QuickActionUM.V2.Receive + is TokenActionsState.ActionState.Stake -> QuickActionUM.V2.Stake + is TokenActionsState.ActionState.YieldMode -> QuickActionUM.V2.YieldMode(action.apy) + else -> null + }?.let(::add) + } + } + }.toImmutableList() + } + + private fun legacyQuickActions(actions: List): ImmutableList { + return buildList { + actions.forEach { action -> + if (action.unavailabilityReason == ScenarioUnavailabilityReason.None) { + when (action) { + is TokenActionsState.ActionState.Buy -> QuickActionUM.V1.Buy + is TokenActionsState.ActionState.Swap -> QuickActionUM.V1.Exchange(action.shouldShowBadge) + is TokenActionsState.ActionState.Receive -> QuickActionUM.V1.Receive + is TokenActionsState.ActionState.Stake -> QuickActionUM.V1.Stake + is TokenActionsState.ActionState.YieldMode -> QuickActionUM.V1.YieldMode(action.apy) + else -> null + }?.let(::add) + } + } + }.toImmutableList() + } } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt index f45c250868..e74695b572 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/PortfolioSelectRow.kt @@ -3,16 +3,15 @@ package com.tangem.common.ui.account import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -22,12 +21,16 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.R import com.tangem.core.ui.components.SpacerW12 import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe +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.domain.models.account.AccountName @Composable @@ -80,6 +83,66 @@ fun PortfolioSelectRow( } } +@Composable +fun PortfolioSelectRowV2( + state: PortfolioSelectUM, + modifier: Modifier = Modifier, + leftContent: @Composable RowScope.() -> Unit = {}, +) { + val leftText = if (state.isAccountMode) R.string.account_details_title else R.string.wc_common_wallet + TangemRowContainer( + modifier.clickable(enabled = state.isMultiChoice, onClick = state.onClick), + ) { + if (state.icon != null) { + Box( + modifier = Modifier.layoutId(TangemRowLayoutId.HEAD), + contentAlignment = Alignment.Center, + ) { + AccountIcon( + modifier = Modifier.padding(end = TangemTheme.dimens2.x3), + name = state.name, + icon = state.icon, + size = AccountIconSize.RedesignedDefault, + ) + } + } + + Row( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + verticalAlignment = Alignment.Bottom, + ) { + leftContent() + Text( + modifier = Modifier.weight(1f), + text = stringResourceSafe(leftText), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + ) + } + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = state.name.resolveReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + ) + if (state.isMultiChoice) { + Icon( + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .size(TangemTheme.dimens2.x5), + painter = painterResource(id = R.drawable.ic_select_choice_20), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.secondary, + ) + } + } +} + @Immutable data class PortfolioSelectUM( val icon: AccountIconUM?, @@ -101,6 +164,20 @@ private fun PortfolioSelectRowPreview(@PreviewParameter(PreviewProvider::class) } } +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PortfolioSelectRowPreviewV2(@PreviewParameter(PreviewProvider::class) state: PortfolioSelectUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + PortfolioSelectRowV2( + state = state, + modifier = Modifier.background(TangemTheme.colors2.surface.level3), + ) + } + } +} + private class PreviewProvider : PreviewParameterProvider { override val values: Sequence diff --git a/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt index 5e0f916630..3793e29d50 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContent.kt @@ -154,7 +154,7 @@ private fun Preview(@PreviewParameter(PreviewProvider::class) state: AddTokenUM) } } -private class PreviewProvider : PreviewParameterProvider { +internal class PreviewProvider : PreviewParameterProvider { private val tokenState get() = TokenItemState.Content( id = UUID.randomUUID().toString(), diff --git a/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt new file mode 100644 index 0000000000..701d39d6c8 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt @@ -0,0 +1,213 @@ +package com.tangem.common.ui.addtoken + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +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 +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.PortfolioSelectRowV2 +import com.tangem.core.ui.R +import com.tangem.core.ui.components.RectangleShimmer +import com.tangem.core.ui.components.SpacerH +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.button.PrimaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonIconPosition +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign + +@Composable +fun AddTokenContentV2(state: AddTokenUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + ) { + TokenHeader( + cryptoCurrencyIconState = state.tokenToAdd.iconState, + tokenName = state.tokenToAdd.titleState, + ) + Column { + if (state.portfolio.isMultiChoice) { + PortfolioSelectRowV2( + modifier = Modifier.background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + state = state.portfolio, + ) + + SpacerH(TangemTheme.dimens2.x2) + } + + NetworkRow(state.network) + } + + SpacerH(TangemTheme.dimens2.x4) + + AddButton( + modifier = Modifier.fillMaxWidth(), + state = state.button, + ) + } +} + +@Composable +private fun TokenHeader( + cryptoCurrencyIconState: CurrencyIconState, + tokenName: TokenItemState.TitleState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x8), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CurrencyIcon( + state = cryptoCurrencyIconState, + iconSize = 70.dp, + shouldDisplayNetwork = false, + ) + + SpacerH(TangemTheme.dimens2.x4) + + when (tokenName) { + is TokenItemState.TitleState.Content -> { + Text( + text = tokenName.text.resolveReference(), + style = TangemTheme.typography2.headingSemibold28, + color = TangemTheme.colors2.text.neutral.primary, + ) + } + TokenItemState.TitleState.Loading -> { + RectangleShimmer( + modifier = Modifier.size(width = TangemTheme.dimens2.x17, height = TangemTheme.dimens2.x9), + radius = TangemTheme.dimens2.x25, + ) + } + TokenItemState.TitleState.Locked -> { + Box( + modifier = Modifier.background( + color = TangemTheme.colors2.surface.level4, + shape = RoundedCornerShape(TangemTheme.dimens2.x25), + ), + ) + } + } + } +} + +@Composable +private fun NetworkRow(state: AddTokenUM.Network, modifier: Modifier = Modifier) { + TangemRowContainer( + modifier + .clickable(enabled = state.editable, onClick = state.onClick) + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + Icon( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .size(TangemTheme.dimens2.x10) + .padding(end = TangemTheme.dimens2.x3), + tint = Color.Unspecified, + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + ) + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = stringResourceSafe(R.string.wc_common_network), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + ) + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + text = state.name.resolveReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + ) + if (state.editable) { + Icon( + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .size(TangemTheme.dimens2.x5), + painter = painterResource(id = com.tangem.common.ui.R.drawable.ic_select_choice_20), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.secondary, + ) + } + } +} + +@Composable +private fun AddButton(state: AddTokenUM.Button, modifier: Modifier = Modifier) { + val isIconExists = state.isEnabled && state.isTangemIconVisible + PrimaryTangemButton( + modifier = modifier, + text = state.text, + onClick = state.onConfirmClick, + tangemIconUM = if (isIconExists) { + TangemIconUM.Icon( + iconRes = R.drawable.ic_tangem_24, + tintReference = { + if (state.isEnabled) { + TangemTheme.colors2.graphic.neutral.primaryInverted + } else { + TangemTheme.colors2.graphic.neutral.quaternary + } + }, + ) + } else { + null + }, + iconPosition = TangemButtonIconPosition.Start, + isEnabled = state.isEnabled, + size = TangemButtonSize.X12, + shape = TangemButtonShape.Rounded, + ) +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PreviewProvider::class) state: AddTokenUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level2) + .padding(horizontal = 16.dp), + ) { + AddTokenContentV2(state = state) + } + } + } +} \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index b3f833f9fd..7c4a7e1cd7 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1177,6 +1177,9 @@ No supported tokens found This QR code contains parameters that are not recognized: %s. Some payment details may be lost if you continue. Unknown Parameters + Credit card or bank account + Share your address or QR-code + Between your portfolios No memo required %1$s (%2$s) on %3$s network %1$s on %2$s network diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt index 939eac7407..38674b5788 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt @@ -30,7 +30,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview enum class AccountIconSize { - Default, Large, Medium, Small, ExtraSmall + Default, Large, Medium, Small, ExtraSmall, RedesignedDefault } /** @@ -128,6 +128,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M AccountIconSize.Medium -> TangemTheme.typography.subtitle1 AccountIconSize.Small -> TangemTheme.typography.subtitle2 AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1 + AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28 } val textSize by animateFloatAsState( @@ -159,6 +160,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) { AccountIconSize.Medium -> 16.dp AccountIconSize.Small -> 12.dp AccountIconSize.ExtraSmall -> 8.dp + AccountIconSize.RedesignedDefault -> 20.dp } private fun AccountIconSize.boxSizeInDp(): Dp = when (this) { @@ -167,6 +169,7 @@ private fun AccountIconSize.boxSizeInDp(): Dp = when (this) { AccountIconSize.Medium -> 28.dp AccountIconSize.Small -> 20.dp AccountIconSize.ExtraSmall -> 14.dp + AccountIconSize.RedesignedDefault -> 40.dp } private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { @@ -175,6 +178,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { AccountIconSize.Medium -> 8.dp AccountIconSize.Small -> 6.dp AccountIconSize.ExtraSmall -> 4.dp + AccountIconSize.RedesignedDefault -> 12.dp } @Preview(showBackground = true) @@ -203,6 +207,7 @@ private fun Sample() { AccountIconSize.Medium -> AccountIconSize.Small AccountIconSize.Small -> AccountIconSize.ExtraSmall AccountIconSize.ExtraSmall -> AccountIconSize.Default + AccountIconSize.RedesignedDefault -> AccountIconSize.Large } }) { Text("Change") } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt index d821328360..3bc40e3242 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/internal/TokenFiatAmount.kt @@ -1,11 +1,7 @@ package com.tangem.core.ui.components.token.internal import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -13,11 +9,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTag -import com.tangem.core.ui.test.TokenElementsTestTags import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach @@ -25,7 +21,10 @@ import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.icons.IconTint import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.extensions.orMaskWithStars +import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.TokenElementsTestTags +import kotlinx.collections.immutable.ImmutableList import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState as TokenFiatAmountState @Composable @@ -60,6 +59,11 @@ internal fun TokenFiatAmount(state: TokenFiatAmountState?, isBalanceHidden: Bool // Empty box for proper measurements Box(modifier) } + is TokenFiatAmountState.AnnotatedContent -> FiatAmountAnnotatedText( + text = state.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + modifier = modifier, + isFlickering = state.isFlickering, + ) null -> Unit } } @@ -77,7 +81,7 @@ private fun IconAmount(state: TokenFiatAmountState.Icon, modifier: Modifier = Mo @Composable private fun ContentFiatAmount( text: String, - icons: List, + icons: ImmutableList, isAmountFlickering: Boolean, modifier: Modifier = Modifier, ) { @@ -135,6 +139,25 @@ private fun FiatAmountText( ) } +@Composable +private fun FiatAmountAnnotatedText( + text: AnnotatedString, + modifier: Modifier = Modifier, + isAvailable: Boolean = true, + isFlickering: Boolean = false, +) { + Text( + modifier = modifier, + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TangemTheme.typography.body2.applyBladeBrush( + isEnabled = isFlickering, + textColor = if (isAvailable) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.tertiary, + ), + ) +} + private fun Modifier.placeholderSize(): Modifier = composed { return@composed this .padding(vertical = 4.dp) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt index beec4ce551..bb3d8c4b4f 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/token/state/TokenItemState.kt @@ -230,6 +230,11 @@ sealed class TokenItemState { val tint: IconTint = IconTint.Inactive, ) : FiatAmountState() + data class AnnotatedContent( + val text: TextReference, + val isFlickering: Boolean = false, + ) : FiatAmountState() + data object Loading : FiatAmountState() data object Locked : FiatAmountState() diff --git a/core/ui/src/main/res/drawable/ic_credit_card_20.xml b/core/ui/src/main/res/drawable/ic_credit_card_20.xml new file mode 100644 index 0000000000..800492e8b1 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_credit_card_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_select_choice_20.xml b/core/ui/src/main/res/drawable/ic_select_choice_20.xml new file mode 100644 index 0000000000..514de3e3a4 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_select_choice_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt index a893a9c1c4..59f8f27bd1 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt @@ -4,15 +4,17 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.ui.addtoken.AddTokenContent +import com.tangem.common.ui.addtoken.AddTokenContentV2 import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedNetwork import com.tangem.features.commonfeatures.api.addtoportfolio.SelectedPortfolio import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -29,10 +31,17 @@ internal class AddTokenComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val state = model.uiState.collectAsStateWithLifecycle() val um = state.value ?: return - AddTokenContent( - modifier = modifier, - state = um, - ) + if (LocalRedesignEnabled.current) { + AddTokenContentV2( + modifier = modifier, + state = um, + ) + } else { + AddTokenContent( + modifier = modifier, + state = um, + ) + } } data class Params( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt index c5e6430356..c4bce69e42 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt @@ -15,10 +15,12 @@ import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.domain.models.TokenReceiveConfig import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContent +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.TokenActionsContentV2 import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -44,10 +46,17 @@ internal class TokenActionsComponent @AssistedInject constructor( val state = model.uiState.collectAsStateWithLifecycle() val bottomSheet by bottomSheetSlot.subscribeAsState() val tokenActionsUM = state.value ?: return - TokenActionsContent( - modifier = modifier, - state = tokenActionsUM, - ) + if (LocalRedesignEnabled.current) { + TokenActionsContentV2( + modifier = modifier, + state = tokenActionsUM, + ) + } else { + TokenActionsContent( + modifier = modifier, + state = tokenActionsUM, + ) + } bottomSheet.child?.instance?.BottomSheet() } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 3749d47c9d..7754a0d1ea 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -51,9 +51,9 @@ private const val TOKEN_ACTIONS_DELAY = 500L @Suppress("LongParameterList", "LargeClass") internal class AddToPortfolioModel @Inject constructor( paramsContainer: ParamsContainer, - designFeatureToggles: DesignFeatureToggles, override val dispatchers: CoroutineDispatcherProvider, val portfolioSelectorController: PortfolioSelectorController, + private val designFeatureToggles: DesignFeatureToggles, private val callbackDelegate: AddToPortfolioCallbackDelegate, private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, @@ -441,7 +441,7 @@ internal class AddToPortfolioModel @Inject constructor( currency = addedToken.currency, accountId = selectedPortfolio.account.account.account.accountId, ).onEach { state -> - val requestedQuickActions = toQuickActions(state.states) + val requestedQuickActions = toQuickActions(state.states, designFeatureToggles.isRedesignEnabled) when { requestedQuickActions.isNotEmpty() -> { timerJob.cancel() @@ -458,6 +458,8 @@ internal class AddToPortfolioModel @Inject constructor( userWallet = selectedPortfolio.userWallet, status = actionsState.cryptoCurrencyStatus, actions = actionsState.states, + isAccountMode = selectedPortfolio.isAccountMode, + account = selectedPortfolio.account.account, ) } } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt index 575c386ebd..7d2aaadc8c 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt @@ -10,17 +10,15 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.transaction.usecase.ReceiveAddressesFactory import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -29,6 +27,7 @@ import javax.inject.Inject internal class TokenActionsModel @Inject constructor( paramsContainer: ParamsContainer, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, tokenActionsIntentsFactory: TokenActionsHandler.Factory, override val dispatchers: CoroutineDispatcherProvider, private val uiBuilder: TokenActionsUiBuilder, @@ -52,13 +51,29 @@ internal class TokenActionsModel @Inject constructor( ) val bottomSheetNavigation: SlotNavigation = SlotNavigation() - val uiState: StateFlow = params.data - .mapLatest { uiBuilder.build(it, tokenActionsHandler, analyticsEventBuilder.first()) } - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = null, - ) + + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = + combine( + params.data, + getBalanceHidingSettingsUseCase.isBalanceHidden(), + ) { cryptoCurrencyData, isBalanceHidden -> + cryptoCurrencyData to isBalanceHidden + } + .mapLatest { (cryptoCurrencyData, isBalanceHidden) -> + uiBuilder.build( + cryptoCurrencyData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + eventBuilder = analyticsEventBuilder.first(), + appCurrency = currentAppCurrency.value, + isBalanceHidden = isBalanceHidden, + ) + } + .stateIn( + scope = modelScope, + started = SharingStarted.Eagerly, + initialValue = null, + ) private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch { val event = analyticsEventBuilder.first().getTokenActionClick(actionUM = handledAction.action) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt index 4b96d1c78a..f461212f82 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt @@ -1,32 +1,73 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.model +import androidx.compose.ui.text.SpanStyle +import com.tangem.common.getTotalCryptoAmount +import com.tangem.common.getTotalFiatAmount +import com.tangem.common.ui.account.* +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.common.ui.markets.action.CryptoCurrencyData import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions import com.tangem.common.ui.markets.action.TokenActionsHandler import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.core.ui.R import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.badge.TangemBadgeIconPosition +import com.tangem.core.ui.ds.badge.TangemBadgeShape +import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.badge.TangemBadgeUM +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM +import java.math.BigDecimal import javax.inject.Inject @ModelScoped internal class TokenActionsUiBuilder @Inject constructor( paramsContainer: ParamsContainer, private val analyticsEventHandler: AnalyticsEventHandler, + private val designFeatureToggles: DesignFeatureToggles, ) { private val params = paramsContainer.require() fun build( - data: CryptoCurrencyData, + cryptoCurrencyData: CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + appCurrency: AppCurrency, + isBalanceHidden: Boolean, + ): TokenActionsUM { + return if (designFeatureToggles.isRedesignEnabled) { + buildV2( + cryptoCurrencyData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + eventBuilder = eventBuilder, + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + ) + } else { + buildV1( + cryptoCurrencyData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + eventBuilder = eventBuilder, + ) + } + } + + private fun buildV1( + cryptoCurrencyData: CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler, eventBuilder: PortfolioAnalyticsEvent.EventBuilder, ): TokenActionsUM { - val status = data.status + val status = cryptoCurrencyData.status val tokenUM = TokenItemState.Content( id = status.currency.id.value, iconState = CryptoCurrencyToIconStateConverter().convert(status.currency), @@ -39,11 +80,147 @@ internal class TokenActionsUiBuilder @Inject constructor( ) return TokenActionsUM( token = tokenUM, + quickActions = quickActions( + cryptoData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = false, + ), onLaterClick = { analyticsEventHandler.send(eventBuilder.getTokenLater()) params.callbacks.onLaterClick() }, - quickActions = quickActions(data, tokenActionsHandler), ) } + + private fun buildV2( + cryptoCurrencyData: CryptoCurrencyData, + tokenActionsHandler: TokenActionsHandler, + eventBuilder: PortfolioAnalyticsEvent.EventBuilder, + appCurrency: AppCurrency, + isBalanceHidden: Boolean, + ): TokenActionsUM { + val status = cryptoCurrencyData.status + val tokenUM = TokenItemState.Content( + id = status.currency.id.value, + iconState = CryptoCurrencyToIconStateConverter().convert(status.currency), + titleState = TokenItemState.TitleState.Content(stringReference(status.currency.name)), + fiatAmountState = createFiatAmountState(status, appCurrency), + subtitle2State = createSubtitle2State(status), + subtitleState = TokenItemState.SubtitleState.TextContent(stringReference(status.currency.symbol)), + onItemClick = null, + onItemLongClick = null, + ) + return TokenActionsUM( + token = tokenUM, + quickActions = quickActions( + cryptoData = cryptoCurrencyData, + tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = true, + ), + onLaterClick = { + analyticsEventHandler.send(eventBuilder.getTokenLater()) + params.callbacks.onLaterClick() + }, + isBalancesHidden = isBalanceHidden, + portfolioBadge = createPortfolioBadge(cryptoCurrencyData), + ) + } + + private fun createPortfolioBadge(cryptoCurrencyData: CryptoCurrencyData): TangemBadgeUM { + val icon: AccountIconUM? + val name = if (cryptoCurrencyData.isAccountMode) { + icon = CryptoPortfolioIconConverter.convert(cryptoCurrencyData.account.account.icon) + cryptoCurrencyData + .account + .account + .accountName + .toUM() + .value + } else { + icon = null + stringReference(cryptoCurrencyData.userWallet.name) + } + return TangemBadgeUM( + text = name, + tangemIconUM = if (icon == null) { + TangemIconUM.Icon( + iconRes = R.drawable.ic_key_card_20, + tintReference = { TangemTheme.colors2.graphic.neutral.tertiary }, + ) + } else { + TangemIconUM.Icon( + iconRes = icon.value.getResId(), + tintReference = { icon.color.getUiColor() }, + ) + }, + size = TangemBadgeSize.X6, + shape = TangemBadgeShape.Rounded, + iconPosition = if (cryptoCurrencyData.isAccountMode) { + TangemBadgeIconPosition.Start + } else { + TangemBadgeIconPosition.End + }, + ) + } + + private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? { + return when (status.value) { + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> { + TokenItemState.Subtitle2State.TextContent( + text = status.getTotalCryptoAmount().format { + crypto(status.currency) + }, + ) + } + is CryptoCurrencyStatus.Loading, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + -> TokenItemState.Subtitle2State.TextContent( + text = BigDecimal.ZERO.format { + crypto(status.currency) + }, + ) + } + } + + private fun createFiatAmountState( + status: CryptoCurrencyStatus, + appCurrency: AppCurrency, + ): TokenItemState.FiatAmountState? { + return when (status.value) { + is CryptoCurrencyStatus.Loaded, + is CryptoCurrencyStatus.Custom, + is CryptoCurrencyStatus.NoQuote, + is CryptoCurrencyStatus.NoAccount, + -> { + TokenItemState.FiatAmountState.AnnotatedContent( + text = status.getTotalFiatAmount().formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).price() + }, + ) + } + is CryptoCurrencyStatus.Unreachable, + is CryptoCurrencyStatus.NoAmount, + is CryptoCurrencyStatus.MissedDerivation, + is CryptoCurrencyStatus.Loading, + -> TokenItemState.FiatAmountState.AnnotatedContent( + text = BigDecimal.ZERO.formatStyled { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).price() + }, + ) + } + } } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt index a682ee6396..a55e97e605 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/ChooseNetworkContent.kt @@ -1,31 +1,47 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.ui import android.content.res.Configuration +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.key +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.label.Label import com.tangem.core.ui.components.label.entity.LabelStyle import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.components.rows.BlockchainRow import com.tangem.core.ui.components.rows.model.BlockchainRowUM +import com.tangem.core.ui.ds.badge.TangemBadge +import com.tangem.core.ui.ds.badge.TangemBadgeShape +import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.ChooseNetworkUM import kotlinx.collections.immutable.persistentListOf import java.util.UUID @@ -33,13 +49,28 @@ private const val DISABLED_ALPHA = 0.4f @Composable internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + if (LocalRedesignEnabled.current) { + ChooseNetworkContentV2( + state = state, + modifier = modifier, + ) + } else { + ChooseNetworkContentV1( + state = state, + modifier = modifier, + ) + } +} + +@Composable +internal fun ChooseNetworkContentV1(state: ChooseNetworkUM, modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(TangemTheme.dimens.radius14)) .background(TangemTheme.colors.background.action), ) { - state.networks.fastForEachIndexed { index, model -> + state.networks.fastForEach { model -> key(model.id) { BlockchainRow( model = model, @@ -66,6 +97,120 @@ internal fun ChooseNetworkContent(state: ChooseNetworkUM, modifier: Modifier = M } } +@Composable +internal fun ChooseNetworkContentV2(state: ChooseNetworkUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(34.dp), + ) + .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2), + ) { + Text( + modifier = Modifier.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2), + text = stringResourceSafe(R.string.common_choose_network), + style = TangemTheme.typography2.subheadlineMedium14, + color = TangemTheme.colors2.text.neutral.tertiary, + ) + + state.networks.fastForEach { model -> + key(model.id) { + TangemRowContainer( + modifier = Modifier.clickable( + enabled = model.isEnabled, + onClick = { state.onNetworkClick(model) }, + ), + contentPadding = PaddingValues(vertical = TangemTheme.dimens2.x3), + content = { + NetworkIcon( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3), + model = model, + ) + + NetworkText( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + model = model, + ) + if (!model.isEnabled) { + TangemBadge( + modifier = Modifier + .layoutId(layoutId = TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x2), + text = resourceReference(R.string.common_added), + size = TangemBadgeSize.X6, + shape = TangemBadgeShape.Rounded, + ) + } + }, + ) + } + } + } +} + +@Composable +private fun NetworkIcon(model: BlockchainRowUM, modifier: Modifier = Modifier) { + if (model.isSelected && model.isEnabled) { + Image( + modifier = modifier + .size(TangemTheme.dimens2.x10), + painter = painterResource(id = model.iconResId), + contentDescription = null, + ) + } else { + Icon( + modifier = modifier + .background( + color = TangemTheme.colors2.button.backgroundSecondary, + shape = CircleShape, + ) + .size(TangemTheme.dimens2.x10), + painter = painterResource(id = model.iconResId), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + ) + } +} + +@Composable +private fun NetworkText(model: BlockchainRowUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Text( + modifier = Modifier.weight(weight = 10f, fill = false), + text = model.name, + style = TangemTheme.typography2.bodyMedium16, + color = when { + model.isEnabled && model.isMainNetwork -> TangemTheme.colors2.text.neutral.primary + model.isEnabled -> TangemTheme.colors2.text.neutral.secondary + else -> TangemTheme.colors2.text.status.disabled + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + modifier = Modifier.weight(weight = 5f, fill = false), + text = model.type, + style = TangemTheme.typography2.captionMedium12, + color = if (model.isEnabled) { + TangemTheme.colors2.text.neutral.tertiary + } else { + TangemTheme.colors2.text.status.disabled + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + @Composable @Preview(showBackground = true, widthDp = 360) @Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) @@ -77,6 +222,19 @@ private fun Preview(@PreviewParameter(ChooseNetworkContentProvider::class) conte } } +@Composable +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewV2(@PreviewParameter(ChooseNetworkContentProvider::class) content: ChooseNetworkUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + ChooseNetworkContent( + state = content, + ) + } + } +} + internal class ChooseNetworkContentProvider : PreviewParameterProvider { private val blockchainRow = BlockchainRowUM( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt index 23d8df105c..1b7f686fb7 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContent.kt @@ -123,7 +123,7 @@ private fun ActionRow( .size(36.dp) .drawWithContent { drawContent() - if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + if (state is QuickActionUM.V1.Exchange && state.shouldShowBadge) { drawBadge(containerColor = containerColor, offset = 4.dp) } }, @@ -190,9 +190,9 @@ private class TokenActionsContentPreviewProvider : PreviewParameterProvider + key(actionUM.title) { + ActionRow( + state = actionUM, + onClick = { state.quickActions.onQuickActionClick(actionUM) }, + onLongClick = { state.quickActions.onQuickActionLongClick(actionUM) }, + ) + } + } + } + + SpacerH(TangemTheme.dimens2.x2) + + SecondaryTangemButton( + modifier = Modifier.fillMaxWidth(), + onClick = state.onLaterClick, + text = resourceReference(R.string.common_later), + size = TangemButtonSize.X12, + shape = TangemButtonShape.Rounded, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ActionRow( + state: QuickActionUM, + onClick: () -> Unit, + onLongClick: (() -> Unit), + modifier: Modifier = Modifier, +) { + val hapticManager = LocalHapticManager.current + val onLongClickInternal = { + hapticManager.perform(TangemHapticEffect.View.LongPress) + onLongClick() + } + + TangemRowContainer( + modifier = modifier + .combinedClickable( + onLongClick = onLongClickInternal.takeIf { state.isLongClickAvailable }, + onClick = { + hapticManager.perform(TangemHapticEffect.View.SegmentTick) + onClick() + }, + ) + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + ) { + Box( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3) + .size(40.dp) + .background( + color = TangemTheme.colors2.graphic.status.accent.copy(alpha = ACTION_BACKGROUND_ALPHA), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = ImageVector.vectorResource(id = state.icon), + contentDescription = null, + tint = TangemTheme.colors2.graphic.status.accent, + ) + } + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = state.title.resolveReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + ) + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = state.description.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + ) + Icon( + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x2) + .size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + ) + } +} + +@Composable +private fun TokenHeader( + addedToken: TokenItemState, + isBalanceHidden: Boolean, + portfolioBadge: TangemBadgeUM?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x8), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CurrencyIcon( + state = addedToken.iconState, + iconSize = 70.dp, + networkBadgeSize = 24.dp, + ) + + SpacerH(TangemTheme.dimens2.x5) + + when (val fiat = addedToken.fiatAmountState) { + is TokenItemState.FiatAmountState.AnnotatedContent -> { + Text( + text = fiat.text.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(), + style = TangemTheme.typography2.titleRegular44, + color = TangemTheme.colors2.text.neutral.primary, + ) + SpacerH(TangemTheme.dimens2.x2) + } + else -> Unit + } + + when (val cryptoAmount = addedToken.subtitle2State) { + is TokenItemState.Subtitle2State.TextContent -> { + Text( + text = cryptoAmount.text.orMaskWithStars(isBalanceHidden), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.secondary, + ) + SpacerH(TangemTheme.dimens2.x2) + } + else -> Unit + } + + SpacerH(TangemTheme.dimens2.x7) + + if (portfolioBadge == null) return + TangemBadge(portfolioBadge) + } +} + +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(TokenActionsContentPreviewProviderV2::class) state: TokenActionsUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors2.surface.level2) + .padding(horizontal = 16.dp), + ) { + TokenActionsContentV2( + state = state, + ) + } + } + } +} + +private class TokenActionsContentPreviewProviderV2 : PreviewParameterProvider { + private val tokenState + get() = TokenItemState.Content( + id = UUID.randomUUID().toString(), + iconState = CurrencyIconState.TokenIcon( + url = null, + topBadgeIconResId = R.drawable.img_eth_22, + fallbackTint = TangemColorPalette.Black, + fallbackBackground = TangemColorPalette.Meadow, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + titleState = TokenItemState.TitleState.Content( + text = stringReference(value = "Tether"), + ), + fiatAmountState = TokenItemState.FiatAmountState.AnnotatedContent( + text = BigDecimal.ONE.formatStyled { + fiat( + fiatCurrencyCode = "USD", + fiatCurrencySymbol = "$", + spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) }, + ).price() + }, + ), + subtitle2State = TokenItemState.Subtitle2State.TextContent( + "1.01 USDT", + ), + subtitleState = TokenItemState.SubtitleState.TextContent(value = stringReference("USDT")), + onItemClick = {}, + onItemLongClick = {}, + ) + + override val values: Sequence + get() = sequenceOf( + TokenActionsUM( + quickActions = QuickActions( + actions = persistentListOf( + QuickActionUM.V2.Buy, + QuickActionUM.V2.Exchange(shouldShowBadge = true), + QuickActionUM.V2.Receive, + ), + onQuickActionClick = {}, + onQuickActionLongClick = {}, + ), + token = tokenState, + onLaterClick = {}, + portfolioBadge = TangemBadgeUM( + text = stringReference("Wallet 2"), + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_key_card_20, + tintReference = { TangemTheme.colors2.graphic.neutral.tertiary }, + ), + size = TangemBadgeSize.X6, + shape = TangemBadgeShape.Rounded, + iconPosition = TangemBadgeIconPosition.End, + ), + ), + ) +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt index b0b0b9b56e..8af3513085 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/state/TokenActionsUM.kt @@ -2,9 +2,12 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state import com.tangem.common.ui.markets.action.QuickActions import com.tangem.core.ui.components.token.state.TokenItemState +import com.tangem.core.ui.ds.badge.TangemBadgeUM internal data class TokenActionsUM( val token: TokenItemState, val quickActions: QuickActions, val onLaterClick: () -> Unit, + val isBalancesHidden: Boolean = false, + val portfolioBadge: TangemBadgeUM? = null, ) \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt index b76d6089cc..1d3df6157b 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt @@ -7,9 +7,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorBS import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorContent +import com.tangem.features.commonfeatures.impl.portfolioselector.ui.PortfolioSelectorContentV2 import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -43,10 +45,17 @@ internal class DefaultPortfolioSelectorComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() - PortfolioSelectorContent( - state = state, - modifier = modifier, - ) + if (LocalRedesignEnabled.current) { + PortfolioSelectorContentV2( + state = state, + modifier = modifier, + ) + } else { + PortfolioSelectorContent( + state = state, + modifier = modifier, + ) + } } @AssistedFactory diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt index cd69452b68..14db910070 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorBS.kt @@ -4,6 +4,7 @@ import android.content.res.Configuration import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -12,8 +13,10 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle +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.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM @@ -41,11 +44,19 @@ internal fun PortfolioSelectorBS( ) }, content = { - PortfolioSelectorContent( - state = state, - contentPadding = PaddingValues(bottom = 16.dp), - modifier = modifier.padding(horizontal = 16.dp), - ) + if (LocalRedesignEnabled.current) { + PortfolioSelectorContentV2( + state = state, + contentPadding = PaddingValues(bottom = 16.dp), + modifier = modifier.padding(horizontal = 16.dp), + ) + } else { + PortfolioSelectorContent( + state = state, + contentPadding = PaddingValues(bottom = 16.dp), + modifier = modifier.padding(horizontal = 16.dp), + ) + } }, ) } @@ -62,4 +73,20 @@ private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::cla onBack = {}, ) } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewV2(@PreviewParameter(PortfolioSelectorPreviewStateProvider::class) params: PortfolioSelectorUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + PortfolioSelectorBS( + state = params, + onDismiss = {}, + modifier = Modifier, + onBack = {}, + ) + } + } } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt new file mode 100644 index 0000000000..1d7b42931f --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/ui/PortfolioSelectorContentV2.kt @@ -0,0 +1,228 @@ +package com.tangem.features.commonfeatures.impl.portfolioselector.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +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.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.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.userwallet.CardImage +import com.tangem.common.ui.userwallet.getBalanceValueAndFlickerState +import com.tangem.common.ui.userwallet.getInformationValue +import com.tangem.common.ui.userwallet.state.UserWalletItemUM +import com.tangem.core.ui.R +import com.tangem.core.ui.components.TextShimmer +import com.tangem.core.ui.components.text.applyBladeBrush +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +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.LocalRedesignEnabled +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorItemUM +import com.tangem.features.commonfeatures.impl.portfolioselector.entity.PortfolioSelectorUM +import com.tangem.utils.StringsSigns.DOT + +private const val DISABLED_WALLET_ALPHA = 0.5f + +@Composable +internal fun PortfolioSelectorContentV2( + state: PortfolioSelectorUM, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), +) { + LazyColumn( + 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, + backgroundColor = TangemTheme.colors2.surface.level3, + radius = TangemTheme.dimens2.x5, + addDefaultPadding = false, + ) + .padding(horizontal = TangemTheme.dimens.spacing16), + ) + } + } + } +} + +@Composable +private fun PortfolioSelectorItem(state: UserWalletItemUM, modifier: Modifier = Modifier) { + TangemRowContainer(modifier = modifier) { + Box( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3), + contentAlignment = Alignment.Center, + ) { + CardImage( + modifier = Modifier.size(40.dp), + imageState = state.imageState, + ) + } + + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + text = state.name.resolveReference(), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + InfoRow( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), + information = state.information, + balance = state.balance, + ) + } +} + +@Composable +private fun InfoRow( + information: UserWalletItemUM.Information, + balance: UserWalletItemUM.Balance, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedContent( + targetState = information, + label = "Information content", + ) { information -> + val informationValue = getInformationValue(information) + + if (informationValue == null) { + TextShimmer( + style = TangemTheme.typography2.captionMedium12, + text = "aaaaa", + ) + } else { + Text( + text = informationValue, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + } + } + + Row { + Text( + text = " $DOT ", + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + ) + + val (balanceValue, isFlickering) = getBalanceValueAndFlickerState(balance) + + if (balanceValue == null) { + TextShimmer( + style = TangemTheme.typography2.captionMedium12, + text = "aaaaa", + ) + } else { + Text( + text = balanceValue, + style = TangemTheme.typography2.captionMedium12.applyBladeBrush( + isEnabled = isFlickering, + textColor = TangemTheme.colors2.text.neutral.secondary, + ), + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun WalletNameRow(model: PortfolioSelectorItemUM.GroupTitle, modifier: Modifier = Modifier) { + Row( + modifier = modifier.padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = model.name.resolveReference(), + style = TangemTheme.typography2.subheadlineMedium14, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_key_card_20), + modifier = Modifier + .align(Alignment.Bottom) + .size(TangemTheme.dimens2.x5), + tint = TangemTheme.colors2.graphic.neutral.tertiary, + contentDescription = null, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview(@PreviewParameter(PortfolioSelectorPreviewStateProvider::class) params: PortfolioSelectorUM) { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + PortfolioSelectorContentV2( + state = params, + modifier = Modifier.background(color = TangemTheme.colors.background.tertiary), + ) + } + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt index 6c8cb07a7c..f4ba22eab4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/MarketsPortfolioDelegate.kt @@ -7,6 +7,7 @@ import com.tangem.common.ui.account.CryptoPortfolioIconConverter import com.tangem.common.ui.account.toUM import com.tangem.common.ui.markets.action.CryptoCurrencyData import com.tangem.common.ui.markets.action.TokenActionsHandler +import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.models.AccountStatusList @@ -53,6 +54,7 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor( private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCaseV2, private val getUserWalletUseCase: GetUserWalletUseCase, private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase, + private val designFeatureToggles: DesignFeatureToggles, isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, @Assisted private val scope: CoroutineScope, @Assisted private val token: TokenMarketParams, @@ -229,6 +231,7 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor( isBalanceHidden = isBalanceHidden, onTokenItemClick = { }, tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = designFeatureToggles.isRedesignEnabled, ) portfolio.portfolios.forEach { portfolioItem -> @@ -253,6 +256,8 @@ internal class MarketsPortfolioDelegate @AssistedInject constructor( userWallet = userWallet, status = currencyStatus, actions = actions, + isAccountMode = isAccountMode, + account = accountWithAdded.accountStatus, ) val expandedKey = portfolioItem.userWallet.walletId to currencyStatus.currency.id val isExpand = expanded.contains(expandedKey) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt index 5474899d5c..30ca070cee 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/model/PortfolioTokenUMConverter.kt @@ -22,6 +22,7 @@ internal class PortfolioTokenUMConverter( private val isBalanceHidden: Boolean, private val onTokenItemClick: (CryptoCurrencyStatus) -> Unit, private val tokenActionsHandler: TokenActionsHandler, + private val isRedesignEnabled: Boolean, ) : Converter { fun convertV2( @@ -38,7 +39,11 @@ internal class PortfolioTokenUMConverter( walletId = value.userWallet.walletId, isBalanceHidden = isBalanceHidden, isQuickActionsShown = isQuickActionsShown, - quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + quickActions = quickActions( + cryptoData = value, + tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = isRedesignEnabled, + ), ) } @@ -57,7 +62,11 @@ internal class PortfolioTokenUMConverter( walletId = value.userWallet.walletId, isBalanceHidden = isBalanceHidden, isQuickActionsShown = false, - quickActions = quickActions(cryptoData = value, tokenActionsHandler = tokenActionsHandler), + quickActions = quickActions( + cryptoData = value, + tokenActionsHandler = tokenActionsHandler, + isRedesignEnabled = isRedesignEnabled, + ), ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt index b1ad93e9b2..8c447cad20 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/PortfolioQuickActions.kt @@ -180,7 +180,7 @@ private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) { ) .size(TangemTheme.dimens.size32) .semantics { - contentDescription = if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + contentDescription = if (state is QuickActionUM.V1.Exchange && state.shouldShowBadge) { "Badge shown" } else { "Badge hidden" @@ -188,7 +188,7 @@ private fun AnimatedVisibilityScope.QuickActionIcon(state: QuickActionUM) { } .drawWithContent { drawContent() - if (state is QuickActionUM.Exchange && state.shouldShowBadge) { + if (state is QuickActionUM.V1.Exchange && state.shouldShowBadge) { drawBadge(containerColor = containerColor, offset = 4.dp) } }, @@ -229,9 +229,9 @@ private fun Preview() { ) { PortfolioQuickActions( actions = persistentListOf( - QuickActionUM.Buy, - QuickActionUM.Exchange(shouldShowBadge = true), - QuickActionUM.Receive, + QuickActionUM.V1.Buy, + QuickActionUM.V1.Exchange(shouldShowBadge = true), + QuickActionUM.V1.Receive, ), isVisible = isVisible, onActionClick = {}, @@ -250,9 +250,9 @@ private fun PreviewRtl() { Box(modifier = Modifier.background(color = TangemTheme.colors.background.action)) { PortfolioQuickActions( actions = persistentListOf( - QuickActionUM.Buy, - QuickActionUM.Exchange(shouldShowBadge = true), - QuickActionUM.Receive, + QuickActionUM.V1.Buy, + QuickActionUM.V1.Exchange(shouldShowBadge = true), + QuickActionUM.V1.Receive, ), isVisible = true, onActionClick = {}, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt index ebc36fbbd5..38675976ef 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolio/impl/ui/preview/PreviewMyPortfolioUMProvider.kt @@ -129,9 +129,9 @@ internal class PreviewMyPortfolioUMProvider : PreviewParameterProvider Date: Mon, 27 Apr 2026 19:04:07 +0400 Subject: [PATCH 115/206] Updated on 2026-08-14 --- .../converters/NetworkCurrencyIdConverter.kt | 14 ++++- .../converters/NetworkAmountsConverterTest.kt | 8 ++- .../NetworkCurrencyIdConverterTest.kt | 61 +++++++++++++++++-- .../NetworkYieldSupplyStatusConverterTest.kt | 8 ++- .../SimpleNetworkStatusConverterTest.kt | 6 +- 5 files changed, 83 insertions(+), 14 deletions(-) diff --git a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt index 8bef0658ac..c8d7b12399 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverter.kt @@ -1,7 +1,9 @@ package com.tangem.data.networks.converters +import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toCoinId +import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId import com.tangem.datasource.local.network.entity.NetworkStatusDM.CurrencyId.Companion.CONTRACT_ADDRESS_DELIMITER import com.tangem.domain.models.currency.CryptoCurrency @@ -22,6 +24,12 @@ internal class NetworkCurrencyIdConverter( private val derivationPath: Network.DerivationPath, ) : TwoWayConverter { + // Cache stores blockchainId in legacy format (e.g. "BTC"), but runtime + // CryptoCurrency.ID expects the new network rawId (e.g. "bitcoin") matching + // Network.rawId built from Blockchain.toNetworkId(). Convert once on construction + // so that IDs reconstructed from cache match those built at runtime. + private val networkRawId: String = Blockchain.fromId(blockchainId).toNetworkId() + override fun convert(value: CurrencyId): CryptoCurrency.ID { val suffixParts = value.value.split(CONTRACT_ADDRESS_DELIMITER) @@ -80,17 +88,17 @@ internal class NetworkCurrencyIdConverter( return when (derivationPath) { is Network.DerivationPath.Card -> { CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( - rawId = blockchainId, + rawId = networkRawId, derivationPath = derivationPath.value, ) } is Network.DerivationPath.Custom -> { CryptoCurrency.ID.Body.NetworkIdWithDerivationPath( - rawId = blockchainId, + rawId = networkRawId, derivationPath = derivationPath.value, ) } - is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(blockchainId) + is Network.DerivationPath.None -> CryptoCurrency.ID.Body.NetworkId(networkRawId) } } } \ No newline at end of file diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt index 4ca75519b9..5175a980de 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkAmountsConverterTest.kt @@ -16,10 +16,14 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkAmountsConverterTest { - private val rawNetworkId = "ethereum" + // Cache stores the SDK-level Blockchain.id (legacy format, e.g. "ETH"). + // The converter normalizes it to the canonical network rawId ("ethereum") via + // Blockchain.fromId(...).toNetworkId() so that resulting CryptoCurrency.IDs match those + // built at runtime from Network.rawId. + private val blockchainId = "ETH" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" - private val converter = NetworkAmountsConverter(blockchainId = rawNetworkId, derivationPath = derivationPath) + private val converter = NetworkAmountsConverter(blockchainId = blockchainId, derivationPath = derivationPath) @Test fun convert() { diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt index 9e129e6cd4..f1c72465ca 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkCurrencyIdConverterTest.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest @@ -15,10 +16,14 @@ import org.junit.jupiter.params.ParameterizedTest @TestInstance(TestInstance.Lifecycle.PER_CLASS) class NetworkCurrencyIdConverterTest { - private val rawNetworkId = "ethereum" + // Legacy SDK format stored in cache (see NetworkStatusDataModelConverter: + // `value.network.toBlockchain().id`). Runtime CryptoCurrency.ID expects the canonical + // network rawId ("ethereum"), so the converter normalizes via Blockchain.fromId(...).toNetworkId(). + private val blockchainId = "ETH" + private val canonicalNetworkRawId = "ethereum" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" - private val converter = NetworkCurrencyIdConverter(blockchainId = rawNetworkId, derivationPath = derivationPath) + private val converter = NetworkCurrencyIdConverter(blockchainId = blockchainId, derivationPath = derivationPath) @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -54,13 +59,13 @@ class NetworkCurrencyIdConverterTest { ConvertModel( value = CurrencyId.createCoinId(""), expected = Result.failure( - IllegalStateException("Coin id is null for $rawNetworkId with $derivationPath"), + IllegalStateException("Coin id is null for $blockchainId with $derivationPath"), ), ), ConvertModel( value = CurrencyId.createCoinId(" "), expected = Result.failure( - IllegalStateException("Coin id is null for $rawNetworkId with $derivationPath"), + IllegalStateException("Coin id is null for $blockchainId with $derivationPath"), ), ), // create token id @@ -184,6 +189,54 @@ class NetworkCurrencyIdConverterTest { ) } + /** + * Regression coverage for [REDACTED_TASK_KEY]. Cache stores `blockchainId` in the legacy SDK format + * (`Blockchain.id`, e.g. "ETH"), but runtime [CryptoCurrency.ID] is built using the canonical + * network rawId (`Blockchain.toNetworkId()`, e.g. "ethereum"). The converter must bridge the + * two formats so that IDs reconstructed from cache equal those built at runtime — otherwise + * `NetworkStatus.Verified.amounts[currency.id]` returns null and the wallet shimmer never clears. + */ + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class LegacyBlockchainIdNormalization { + + @Test + fun `convert with legacy ETH blockchainId produces id with canonical ethereum rawId`() { + val cached = CurrencyId.createCoinId("ethereum") + + val result = converter.convert(cached) + + Truth.assertThat(result) + .isEqualTo(CryptoCurrency.ID.fromValue("coin⟨$canonicalNetworkRawId→$derivationPathHashCode⟩ethereum")) + } + + @Test + fun `convert with legacy BTC blockchainId produces id with canonical bitcoin rawId`() { + val btcDerivationPath = Network.DerivationPath.Card(value = "m/44'/0'/0'/0/0") + val btcDerivationHash = btcDerivationPath.value.hashCode() + val btcConverter = NetworkCurrencyIdConverter( + blockchainId = "BTC", + derivationPath = btcDerivationPath, + ) + val cached = CurrencyId.createCoinId("bitcoin") + + val result = btcConverter.convert(cached) + + Truth.assertThat(result) + .isEqualTo(CryptoCurrency.ID.fromValue("coin⟨bitcoin→$btcDerivationHash⟩bitcoin")) + } + + @Test + fun `convert and convertBack roundtrip preserves CurrencyId`() { + val cached = CurrencyId.createCoinId("ethereum") + + val runtimeId = converter.convert(cached) + val roundTrip = converter.convertBack(runtimeId) + + Truth.assertThat(roundTrip).isEqualTo(cached) + } + } + data class ConvertModel(val value: CurrencyId, val expected: Result) data class ConvertBackModel(val value: CryptoCurrency.ID, val expected: Result) diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt index b18f2e14d2..53e5bc65a6 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/NetworkYieldSupplyStatusConverterTest.kt @@ -13,10 +13,14 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class NetworkYieldSupplyStatusConverterTest { - private val rawNetworkId = "ethereum" + // Cache stores the SDK-level Blockchain.id (legacy format, e.g. "ETH"). + // The converter normalizes it to the canonical network rawId ("ethereum") via + // Blockchain.fromId(...).toNetworkId() so that resulting CryptoCurrency.IDs match those + // built at runtime from Network.rawId. + private val blockchainId = "ETH" private val derivationPath = Network.DerivationPath.Card(value = "m/44'/60'/0'/0/0") private val derivationPathHashCode = "-1843072795" - private val converter = NetworkYieldSupplyStatusConverter(rawNetworkId, derivationPath) + private val converter = NetworkYieldSupplyStatusConverter(blockchainId, derivationPath) private val domainStatus = YieldSupplyStatus( isActive = true, diff --git a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt index 0af092a1ca..d28e1c3f28 100644 --- a/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt +++ b/data/networks/src/test/java/com/tangem/data/networks/converters/SimpleNetworkStatusConverterTest.kt @@ -96,12 +96,12 @@ internal class SimpleNetworkStatusConverterTest { ), ), amounts = mapOf( - ID.fromValue("coin⟨ETH→3046160⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), - ID.fromValue("token⟨ETH→3046160⟩usdt⚓0x1") to Amount.Loaded(value = BigDecimal.ZERO), + ID.fromValue("coin⟨ethereum→3046160⟩ethereum") to Amount.Loaded(value = BigDecimal.ZERO), + ID.fromValue("token⟨ethereum→3046160⟩usdt⚓0x1") to Amount.Loaded(value = BigDecimal.ZERO), ), pendingTransactions = emptyMap(), yieldSupplyStatuses = mapOf( - ID.fromValue("coin⟨ETH→3046160⟩ethereum") to YieldSupplyStatus( + ID.fromValue("coin⟨ethereum→3046160⟩ethereum") to YieldSupplyStatus( isActive = false, isInitialized = false, isAllowedToSpend = false, From 1964b3cfad1749cd649b7ff360b7c3438c55783d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Apr 2026 21:10:26 +0400 Subject: [PATCH 116/206] Updated on 2026-08-14 --- .../com/tangem/common/TangemBlogUrlBuilder.kt | 24 +++-- .../com/tangem/utils/SupportedLanguages.kt | 11 ++- .../com/tangem/utils/TangemBlogUrlBuilder.kt | 28 ------ .../tangem/utils/SupportedLanguagesTest.kt | 94 +++++++++++++++++++ .../common/locale/DefaultLocaleProvider.kt | 28 ------ .../data/common/locale/LocaleProvider.kt | 13 --- .../common/locale/di/LocaleProviderModule.kt | 20 ---- .../approval/impl/model/GiveApprovalModel.kt | 19 +--- .../features/details/model/DetailsModel.kt | 13 +-- .../impl/presentation/model/StakingModel.kt | 11 ++- .../model/StakingModelNavigationTest.kt | 17 +++- .../tangem/feature/swap/model/SwapModel.kt | 17 ++-- .../tangem/feature/swap/ui/StateBuilder.kt | 4 +- .../model/ExpressTransactionsModel.kt | 7 ++ .../model/TokenDetailsClickIntents.kt | 2 + .../tokendetails/model/TokenDetailsModel.kt | 7 ++ ...enDetailsSwapTransactionsStateConverter.kt | 11 +-- .../model/WcSendTransactionModel.kt | 12 +-- .../active/model/YieldSupplyActiveModel.kt | 8 +- .../impl/promo/model/YieldSupplyPromoModel.kt | 19 ++-- .../approve/model/YieldSupplyApproveModel.kt | 6 +- .../model/YieldSupplyStopEarningModel.kt | 6 +- 22 files changed, 204 insertions(+), 173 deletions(-) delete mode 100644 core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt create mode 100644 core/utils/src/test/kotlin/com/tangem/utils/SupportedLanguagesTest.kt delete mode 100644 data/common/src/main/kotlin/com/tangem/data/common/locale/DefaultLocaleProvider.kt delete mode 100644 data/common/src/main/kotlin/com/tangem/data/common/locale/LocaleProvider.kt delete mode 100644 data/common/src/main/kotlin/com/tangem/data/common/locale/di/LocaleProviderModule.kt diff --git a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt index b62a7c78ce..28c76f1551 100644 --- a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt +++ b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt @@ -16,14 +16,6 @@ object TangemBlogUrlBuilder { val path: String - data object SeedNotify : Post { - override val path: String = "seed-notify" - } - - data object SeedNotifySecond : Post { - override val path: String = "tangem-resolves-log-issue" - } - data object SeedPhraseRiskySolution : Post { override val path: String = "seed-phrase-faq" } @@ -39,5 +31,21 @@ object TangemBlogUrlBuilder { data object HowToScan : Post { override val path: String = "scan-tangem-card" } + + data object HowToStake : Post { + override val path: String = "how-to-stake-cryptocurrency" + } + + data object GiveRevokePermission : Post { + override val path: String = "give-revoke-permission" + } + + data object HowYieldModeWorks : Post { + override val path: String = "yield-mode" + } + + data object AboutCrossChainBridges : Post { + override val path: String = "an-overview-of-cross-chain-bridges" + } } } \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt b/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt index 420547d13c..07fc6985b2 100644 --- a/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt +++ b/core/utils/src/main/java/com/tangem/utils/SupportedLanguages.kt @@ -13,7 +13,7 @@ object SupportedLanguages { const val CHINESE = "zh" const val SPANISH = "es" - val supportedLangugeCodes = listOf( + val supportedLanguageCodes = listOf( ENGLISH, RUSSIAN, GERMAN, @@ -25,10 +25,17 @@ object SupportedLanguages { SPANISH, ) + /** + * Returns the ISO 639-1 code of the device's current language when it belongs to + * [supportedLanguageCodes], otherwise falls back to [ENGLISH]. + * + * Intended for callers that need a plain two-letter language code (e.g. URL path segments + * like `tangem.com/{en|ru}/...`). + */ fun getCurrentSupportedLanguageCode(): String { val locale = Locale.getDefault() - return if (supportedLangugeCodes.contains(locale.language)) { + return if (supportedLanguageCodes.contains(locale.language)) { locale.language } else { ENGLISH diff --git a/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt b/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt deleted file mode 100644 index d24267a527..0000000000 --- a/core/utils/src/main/java/com/tangem/utils/TangemBlogUrlBuilder.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.utils - -import java.util.Locale - -@Deprecated("Use TangemBlogUrlBuilder from common module") -object TangemBlogUrlBuilder { - - private const val RU_LOCALE = "ru" - private const val EN_LOCALE = "en" - - private const val TANGEM_MAIN = "https://tangem.com/" - - val FEE_BLOG_LINK: String - get(): String { - val locale = if (Locale.getDefault().language == RU_LOCALE) RU_LOCALE else EN_LOCALE - return buildString { - append(TANGEM_MAIN) - append(locale) - append("/blog/post/what-is-a-transaction-fee-and-why-do-we-need-it/") - } - } - - const val RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP = "https://tangem.com/en/blog/post/give-revoke-permission/" - - const val YIELD_SUPPLY_HOW_IT_WORKS_URL = "https://tangem.com/en/blog/post/yield-mode" - const val YIELD_SUPPLY_TOS_URL = "https://aave.com/terms-of-service" - const val YIELD_SUPPLY_PRIVACY_URL = "https://aave.com/privacy-policy" -} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/SupportedLanguagesTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/SupportedLanguagesTest.kt new file mode 100644 index 0000000000..3e3729e3f8 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/SupportedLanguagesTest.kt @@ -0,0 +1,94 @@ +package com.tangem.utils + +import com.google.common.truth.Truth +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.util.Locale + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SupportedLanguagesTest { + + private lateinit var originalLocale: Locale + + @BeforeEach + fun setUp() { + originalLocale = Locale.getDefault() + } + + @AfterEach + fun tearDown() { + Locale.setDefault(originalLocale) + } + + @Test + fun `getCurrentSupportedLanguageCode returns primary language when locale is supported`() { + // Arrange + Locale.setDefault(Locale("en", "US")) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo("en") + } + + @Test + fun `getCurrentSupportedLanguageCode drops region for supported language`() { + // Arrange + Locale.setDefault(Locale("zh", "CN")) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo("zh") + } + + @Test + fun `getCurrentSupportedLanguageCode returns ENGLISH when locale is not supported`() { + // Arrange — pt (Portuguese) is not in supportedLanguageCodes + Locale.setDefault(Locale("pt", "BR")) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo(SupportedLanguages.ENGLISH) + } + + @Test + fun `getCurrentSupportedLanguageCode returns ENGLISH for empty language`() { + // Arrange + Locale.setDefault(Locale("", "")) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo(SupportedLanguages.ENGLISH) + } + + @Test + fun `getCurrentSupportedLanguageCode supports every code in supportedLanguageCodes`() { + SupportedLanguages.supportedLanguageCodes.forEach { code -> + // Arrange + Locale.setDefault(Locale(code)) + + // Act + val actual = SupportedLanguages.getCurrentSupportedLanguageCode() + + // Assert + Truth.assertThat(actual).isEqualTo(code) + } + } + + @Test + fun `supportedLanguageCodes contains the expected nine ISO 639-1 codes`() { + // Assert + Truth.assertThat(SupportedLanguages.supportedLanguageCodes) + .containsExactly("en", "ru", "de", "fr", "it", "ja", "uk", "zh", "es") + .inOrder() + } +} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/locale/DefaultLocaleProvider.kt b/data/common/src/main/kotlin/com/tangem/data/common/locale/DefaultLocaleProvider.kt deleted file mode 100644 index 55cdf424ec..0000000000 --- a/data/common/src/main/kotlin/com/tangem/data/common/locale/DefaultLocaleProvider.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.data.common.locale - -import java.util.Locale - -/** -[REDACTED_AUTHOR] - */ -internal class DefaultLocaleProvider : LocaleProvider { - - override fun getLocale(): Locale { - return Locale.getDefault() - } - - override fun getWebUriLocaleLanguage(): String { - val language = getLocale().language - return if (LOCALE_LANG_RU.equals(language, true) || LOCALE_LANG_BY.equals(language, true)) { - LOCALE_LANG_RU - } else { - LOCALE_LANG_EN - } - } - - companion object { - const val LOCALE_LANG_RU = "ru" - const val LOCALE_LANG_BY = "by" - const val LOCALE_LANG_EN = "en" - } -} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/locale/LocaleProvider.kt b/data/common/src/main/kotlin/com/tangem/data/common/locale/LocaleProvider.kt deleted file mode 100644 index 5f52c75915..0000000000 --- a/data/common/src/main/kotlin/com/tangem/data/common/locale/LocaleProvider.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tangem.data.common.locale - -import java.util.Locale - -/** -[REDACTED_AUTHOR] - */ -interface LocaleProvider { - - fun getLocale(): Locale - - fun getWebUriLocaleLanguage(): String -} \ No newline at end of file diff --git a/data/common/src/main/kotlin/com/tangem/data/common/locale/di/LocaleProviderModule.kt b/data/common/src/main/kotlin/com/tangem/data/common/locale/di/LocaleProviderModule.kt deleted file mode 100644 index 656c6ed529..0000000000 --- a/data/common/src/main/kotlin/com/tangem/data/common/locale/di/LocaleProviderModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.data.common.locale.di - -import com.tangem.data.common.locale.DefaultLocaleProvider -import com.tangem.data.common.locale.LocaleProvider -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object LocaleProviderModule { - - @Provides - @Singleton - fun provideCacheRegistry(): LocaleProvider { - return DefaultLocaleProvider() - } -} \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt index 727c03292f..1cecfc51ea 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt @@ -8,6 +8,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.TransactionSender.MultipleTransactionSendMode import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -16,10 +17,7 @@ import com.tangem.core.analytics.models.Basic 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.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError @@ -37,7 +35,6 @@ import com.tangem.features.approval.api.GiveApprovalComponent import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback import com.tangem.features.send.v2.api.entity.FeeItem import com.tangem.features.send.v2.api.entity.FeeSelectorUM -import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.MutableStateFlow @@ -61,7 +58,6 @@ internal class GiveApprovalModel @Inject constructor( private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase, private val getFeeForTokenUseCase: GetFeeForTokenUseCase, private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, - private val uiMessageSender: UiMessageSender, private val urlOpener: UrlOpener, private val getUserWalletUseCase: GetUserWalletUseCase, private val analyticsEventHandler: AnalyticsEventHandler, @@ -119,16 +115,9 @@ internal class GiveApprovalModel @Inject constructor( } fun onOpenLearnMoreAboutApproveClick() { - urlOpener.openUrl(RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP) - } - - fun showPermissionInfoDialog() { - uiMessageSender.send( - DialogMessage( - message = resourceReference(com.tangem.common.ui.R.string.give_permission_staking_footer), - title = resourceReference(com.tangem.common.ui.R.string.common_approve), - ), - ) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.GiveRevokePermission)) + } } suspend fun loadFee(): Either { diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index 394aa04245..840d7776a1 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -1,8 +1,6 @@ package com.tangem.features.details.model -import android.content.res.Resources import arrow.core.getOrElse -import com.tangem.utils.logging.TangemLogger import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -37,6 +35,7 @@ import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.info.AppInfoProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.launchIn @@ -44,7 +43,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import java.util.Locale import javax.inject.Inject @ModelScoped @@ -270,13 +268,4 @@ internal class DetailsModel @Inject constructor( } private fun getAppVersion(): String = "${appInfoProvider.appVersion} (${appInfoProvider.appVersionCode})" - - private companion object { - val SYSTEM_LANGUAGE = runCatching { Resources.getSystem().configuration.locales[0].language }.getOrElse { "" } - val APP_LANGUAGE = Locale.getDefault().language - val UTM_MARKS = "utm_source=tangem-app" + - "&utm_medium=app" + - "&utm_campaign=users-$SYSTEM_LANGUAGE" + - "&utm_content=devicelang-$APP_LANGUAGE" - } } \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index f3fbf4671e..0ed6ee204a 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -8,6 +8,7 @@ import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.getValidatorsCount import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData @@ -95,7 +96,6 @@ import com.tangem.features.staking.impl.presentation.state.utils.isSingleAction import com.tangem.features.staking.impl.presentation.state.utils.withStubUnstakeAction import com.tangem.lib.crypto.BlockchainUtils.isTon import com.tangem.utils.Provider -import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP import com.tangem.utils.coroutines.* import com.tangem.utils.extensions.isSingleItem import com.tangem.utils.extensions.orZero @@ -603,7 +603,9 @@ internal class StakingModel @Inject constructor( override fun onInitialInfoBannerClick() { analyticsEventHandler.send(StakingAnalyticsEvent.WhatIsStaking()) - innerRouter.openUrl(WHAT_IS_STAKING_ARTICLE_URL) + modelScope.launch { + innerRouter.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.HowToStake)) + } } override fun onInfoClick(infoType: InfoType) { @@ -1100,7 +1102,9 @@ internal class StakingModel @Inject constructor( } override fun onOpenLearnMoreAboutApproveClick() { - urlOpener.openUrl(RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.GiveRevokePermission)) + } } override fun onActivateTonAccountNotificationClick() { @@ -1495,7 +1499,6 @@ internal class StakingModel @Inject constructor( } private companion object { - const val WHAT_IS_STAKING_ARTICLE_URL = "https://tangem.com/en/blog/post/how-to-stake-cryptocurrency/" const val ALLOWANCE_UPDATE_DELAY = 10_000L } } \ No newline at end of file diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt index c933a896b6..00d51a6c28 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelNavigationTest.kt @@ -1,6 +1,7 @@ package com.tangem.features.staking.impl.presentation.model import arrow.core.Either +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.core.analytics.models.Basic import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.message.DialogMessage @@ -300,21 +301,24 @@ internal class StakingModelNavigationTest : StakingModelTestBase() { @Test fun `WHEN onInitialInfoBannerClick THEN analytics sent and url opened`() = runTest { + val expectedUrl = "https://tangem.com/blog/post/how-to-stake-cryptocurrency/?utm_source=tangem-app" + mockkObject(TangemBlogUrlBuilder) + coEvery { TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.HowToStake) } returns expectedUrl every { innerRouter.openUrl(any()) } just Runs val model = createModel(testScope = this) advanceUntilIdle() model.onInitialInfoBannerClick() + advanceUntilIdle() verify { analyticsEventHandler.send(match { it is StakingAnalyticsEvent.WhatIsStaking }) } - verify { - innerRouter.openUrl("https://tangem.com/en/blog/post/how-to-stake-cryptocurrency/") - } + verify { innerRouter.openUrl(expectedUrl) } model.onDestroy() + unmockkObject(TangemBlogUrlBuilder) } @Test @@ -478,15 +482,20 @@ internal class StakingModelNavigationTest : StakingModelTestBase() { @Test fun `WHEN onOpenLearnMoreAboutApproveClick THEN urlOpener opens approve url`() = runTest { + val expectedUrl = "https://tangem.com/blog/post/give-revoke-permission/?utm_source=tangem-app" + mockkObject(TangemBlogUrlBuilder) + coEvery { TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.GiveRevokePermission) } returns expectedUrl every { urlOpener.openUrl(any()) } just Runs val model = createModel(testScope = this) advanceUntilIdle() model.onOpenLearnMoreAboutApproveClick() + advanceUntilIdle() - verify { urlOpener.openUrl("https://tangem.com/en/blog/post/give-revoke-permission/") } + verify { urlOpener.openUrl(expectedUrl) } + unmockkObject(TangemBlogUrlBuilder) model.onDestroy() } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 1cc6c4cd61..c1e58f73df 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -96,6 +96,7 @@ import com.tangem.feature.swap.utils.getContractAddress import com.tangem.features.approval.api.GiveApprovalComponent import com.tangem.features.send.v2.api.entity.FeeSelectorUM import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.features.swap.SwapComponent import com.tangem.utils.Provider import com.tangem.utils.coroutines.* @@ -1357,12 +1358,16 @@ internal class SwapModel @Inject constructor( val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val txFeeState = dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState ?: return@UiActions - uiState = stateBuilder.showSelectFeeBottomSheet( - uiState = uiState, - selectedFee = selectedFee, - txFeeState = txFeeState, - ) { - uiState = stateBuilder.dismissBottomSheet(uiState) + modelScope.launch { + val readMoreUrl = TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee) + uiState = stateBuilder.showSelectFeeBottomSheet( + uiState = uiState, + selectedFee = selectedFee, + txFeeState = txFeeState, + readMoreUrl = readMoreUrl, + ) { + uiState = stateBuilder.dismissBottomSheet(uiState) + } } }, onSelectFeeType = { txFee -> diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 2c2551b3d5..61d9d800cd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -37,7 +37,6 @@ import com.tangem.utils.Provider import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.TILDE_SIGN -import com.tangem.utils.TangemBlogUrlBuilder.FEE_BLOG_LINK import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -984,6 +983,7 @@ internal class StateBuilder( uiState: SwapStateHolder, selectedFee: FeeType, txFeeState: TxFeeState.MultipleFeeState, + readMoreUrl: String, onDismiss: () -> Unit, ): SwapStateHolder { val config = ChooseFeeBottomSheetConfig( @@ -995,7 +995,7 @@ internal class StateBuilder( } actions.onSelectFeeType.invoke(selectedItem) }, - readMoreUrl = FEE_BLOG_LINK, + readMoreUrl = readMoreUrl, feeItems = txFeeState.toFeeItemState(), readMore = resourceReference(R.string.common_read_more), onReadMoreClick = actions.onLinkClick, diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt index bdeb591c42..e69efad8e5 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/ExpressTransactionsModel.kt @@ -2,6 +2,7 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model import androidx.compose.runtime.Stable import arrow.core.getOrElse +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM import com.tangem.common.ui.expressStatus.state.ExpressTransactionsBlockState @@ -114,6 +115,12 @@ internal class ExpressTransactionsModel @Inject constructor( router.openUrl(url) } + override fun onReadAboutCrossChainBridgesClick() { + modelScope.launch { + router.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.AboutCrossChainBridges)) + } + } + override fun onConfirmDisposeExpressStatus() { uiMessageSender.send( DialogMessage( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index 5518cfdedb..dcee418428 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -100,6 +100,8 @@ interface ExpressTransactionsClickIntents { fun onOpenUrlClick(url: String) + fun onReadAboutCrossChainBridgesClick() + fun onConfirmDisposeExpressStatus() fun onDisposeExpressStatus() diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index b8b37a945e..8423c533ab 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -13,6 +13,7 @@ import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.common.ui.bottomsheet.receive.AddressModel @@ -960,6 +961,12 @@ internal class TokenDetailsModel @Inject constructor( router.openUrl(url) } + override fun onReadAboutCrossChainBridgesClick() { + modelScope.launch { + router.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.AboutCrossChainBridges)) + } + } + override fun onSwapPromoDismiss(promoId: PromoId) { modelScope.launch(dispatchers.main) { shouldShowPromoTokenUseCase.neverToShow(promoId) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index c55c898406..e5d7a1341b 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -39,7 +39,6 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal -import java.util.Locale // Fixme [REDACTED_JIRA] @Suppress("LargeClass") @@ -232,7 +231,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( } else { ExchangeStatusNotification.TokenRefunded( cryptoCurrency = refundToken, - onReadMoreClick = { clickIntents.onOpenUrlClick(url = getAboutCrossChainBridgesLink()) }, + onReadMoreClick = clickIntents::onReadAboutCrossChainBridgesClick, onGoToTokenClick = { clickIntents.onGoToRefundedTokenClick(refundToken) }, ) } @@ -424,12 +423,4 @@ internal class TokenDetailsSwapTransactionsStateConverter( isDone = isSendingDone, ) } - - private fun getAboutCrossChainBridgesLink(): String { - return if (Locale.getDefault().country == "RU") { - "https://tangem.com/ru/blog/post/an-overview-of-cross-chain-bridges/" - } else { - "https://tangem.com/en/blog/post/an-overview-of-cross-chain-bridges/" - } - } } \ No newline at end of file diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index b6c43240ab..85ce399ac9 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -12,6 +12,7 @@ import com.domain.blockaid.models.transaction.SimulationResult import com.domain.blockaid.models.transaction.ValidationResult import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -59,7 +60,6 @@ import com.tangem.features.walletconnect.transaction.entity.send.WcSendTransacti import com.tangem.features.walletconnect.transaction.routes.WcTransactionRoutes import com.tangem.features.walletconnect.transaction.ui.blockaid.WcSendAndReceiveBlockAidUiConverter import com.tangem.features.walletconnect.utils.WcNotificationsFactory -import com.tangem.utils.SupportedLanguages import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -301,14 +301,10 @@ internal class WcSendTransactionModel @Inject constructor( stackNavigation.pop() } - @Deprecated("Use TangemBlockUrlBuilder instead") private fun onApproveLearnMoreClick() { - val code = SupportedLanguages.getCurrentSupportedLanguageCode() - .takeIf { it == SupportedLanguages.RUSSIAN } - ?: SupportedLanguages.ENGLISH - - val url = "https://tangem.com/$code/blog/post/give-revoke-permission/" - urlOpener.openUrl(url) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.GiveRevokePermission)) + } } private fun isMultipleSignRequired(useCase: WcSignUseCase<*>): Boolean { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt index 92b9ebf190..4da4c1922b 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt @@ -4,6 +4,7 @@ import arrow.core.getOrElse import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -34,13 +35,12 @@ import com.tangem.features.yield.supply.impl.active.model.transformers.YieldSupp import com.tangem.features.yield.supply.impl.subcomponents.approve.YieldSupplyApproveComponent import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent import com.tangem.utils.StringsSigns.DASH_SIGN -import com.tangem.utils.TangemBlogUrlBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @Suppress("LongParameterList", "LargeClass") @@ -160,7 +160,9 @@ internal class YieldSupplyActiveModel @Inject constructor( } fun onReadMoreClick() { - urlOpener.openUrl(TangemBlogUrlBuilder.YIELD_SUPPLY_HOW_IT_WORKS_URL) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.HowYieldModeWorks)) + } } private fun subscribeOnCurrencyStatusUpdates() { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt index a1384942e1..438a5ceb15 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/promo/model/YieldSupplyPromoModel.kt @@ -2,6 +2,7 @@ package com.tangem.features.yield.supply.impl.promo.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped @@ -11,13 +12,12 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.features.yield.supply.api.YieldSupplyPromoComponent -import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics +import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.promo.YieldSupplyPromoConfig import com.tangem.features.yield.supply.impl.promo.entity.YieldSupplyPromoUM -import com.tangem.utils.TangemBlogUrlBuilder -import com.tangem.utils.TangemBlogUrlBuilder.YIELD_SUPPLY_HOW_IT_WORKS_URL import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.launch import javax.inject.Inject @ModelScoped @@ -32,8 +32,8 @@ internal class YieldSupplyPromoModel @Inject constructor( val params: YieldSupplyPromoComponent.Params = paramsContainer.require() val uiState: YieldSupplyPromoUM = YieldSupplyPromoUM( - tosLink = TangemBlogUrlBuilder.YIELD_SUPPLY_TOS_URL, - policyLink = TangemBlogUrlBuilder.YIELD_SUPPLY_PRIVACY_URL, + tosLink = AAVE_TOS_URL, + policyLink = AAVE_PRIVACY_URL, tokenSymbol = params.currency.symbol, title = resourceReference( R.string.yield_module_promo_screen_title_v2, @@ -69,10 +69,17 @@ internal class YieldSupplyPromoModel @Inject constructor( } override fun onHowItWorksClick() { - urlOpener.openUrl(YIELD_SUPPLY_HOW_IT_WORKS_URL) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.HowYieldModeWorks)) + } } override fun onStartEarningClick() { bottomSheetNavigation.activate(YieldSupplyPromoConfig.Action) } + + private companion object { + const val AAVE_TOS_URL = "https://aave.com/terms-of-service" + const val AAVE_PRIVACY_URL = "https://aave.com/privacy-policy" + } } \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt index cdd36725d5..1cbf1b303f 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt @@ -3,6 +3,7 @@ package com.tangem.features.yield.supply.impl.subcomponents.approve.model import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -39,7 +40,6 @@ import com.tangem.features.yield.supply.impl.subcomponents.approve.YieldSupplyAp import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsComponent import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsUpdateTrigger import com.tangem.features.yield.supply.impl.subcomponents.notifications.entity.YieldSupplyNotificationData -import com.tangem.utils.TangemBlogUrlBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update @@ -113,7 +113,9 @@ internal class YieldSupplyApproveModel @Inject constructor( } fun onReadMoreClick() { - urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee)) + } } override fun onFeeReload() { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index 3ab8efa5ae..0a339baa9b 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.yield.supply.impl.subcomponents.stopearning.model import arrow.core.getOrElse +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -39,7 +40,6 @@ import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSu import com.tangem.features.yield.supply.impl.subcomponents.notifications.entity.YieldSupplyNotificationData import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent import com.tangem.features.yield.supply.impl.subcomponents.stopearning.model.transformer.YieldSupplyStopEarningFeeContentTransformer -import com.tangem.utils.TangemBlogUrlBuilder import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import com.tangem.utils.logging.TangemLogger @@ -128,7 +128,9 @@ internal class YieldSupplyStopEarningModel @Inject constructor( } fun onReadMoreClick() { - urlOpener.openUrl(TangemBlogUrlBuilder.FEE_BLOG_LINK) + modelScope.launch { + urlOpener.openUrl(TangemBlogUrlBuilder.build(TangemBlogUrlBuilder.Post.WhatIsTransactionFee)) + } } fun onClick() { From a44f392d0a3b3a40a18a54d7d3698c8b4f0d5dfa Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Apr 2026 20:51:40 +0300 Subject: [PATCH 117/206] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 2 +- core/res/src/main/res/values-es/strings.xml | 2 +- core/res/src/main/res/values-fr/strings.xml | 4 +- core/res/src/main/res/values-it/strings.xml | 2 +- core/res/src/main/res/values-ja/strings.xml | 28 +++++++++++- .../src/main/res/values-pt-rBR/strings.xml | 43 ++++++++++++++++++- core/res/src/main/res/values-ru/strings.xml | 5 ++- .../src/main/res/values-uk-rUA/strings.xml | 2 +- .../src/main/res/values-zh-rCN/strings.xml | 17 +++++++- core/res/src/main/res/values/strings.xml | 11 +++-- gradle/tangem_dependencies.toml | 2 +- 11 files changed, 103 insertions(+), 15 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index de1e4a59f4..c182833574 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1596,7 +1596,7 @@ Aufladeoptionen Zu Google Wallet hinzufügen Kartennummer - PIN ändern + PIN-Code Die Karte ist vollständig für Zahlungen bereit. PIN-Code erstellt CVC diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 1344cbb70a..380471b947 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1579,7 +1579,7 @@ Opciones de recarga Añadir a Google Wallet Número de tarjeta - Modificar PIN + Código PIN La tarjeta está completamente lista para pagos. Código PIN creado CVC diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 86ff4ac437..3114f14a53 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1523,7 +1523,7 @@ Ajouter des fonds Options de recharge Numéro de carte - Modifier le code PIN + Code PIN La carte est totalement prête pour les paiements. Code PIN créé CVC @@ -1736,7 +1736,7 @@ Tout est en cours de préparation ! Autre portefeuille Créez un code à 4 chiffres. Il servira pour les paiements. - code PIN + Code PIN Créer un code PIN Le code PIN n\'a pas été accepté. Veuillez réessayer ou utiliser un autre code. Code PIN invalide : évitez les séquences ou les répétitions diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 917756add9..22cdd2be2d 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -103,7 +103,7 @@ Aggiungi fondi Opzioni di ricarica Numero carta - Modifica PIN + PIN Code La carta è completamente pronta per i pagamenti. Codice PIN creato CVC diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 2797beb6c3..bc306eab97 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -216,6 +216,7 @@ アカウント 有効化 追加 + 資金を追加 ポートフォリオに追加 トークンを追加 トークンの追加 @@ -269,14 +270,19 @@ + + %d日前 + 削除 無効にする 無効 + 無効化中 切断 完了 編集 有効にする 有効 + 有効化中 エラー 入金時のネットワーク手数料 スワップ @@ -320,6 +326,7 @@ %d分前 + さらに ネットワーク手数料 送金額は、選択された手数料レベルをカバーするため、%1$s (%2$s) 減額されます。 @@ -364,6 +371,7 @@ アクションを選択 売る 送る + 送金: 取引の送信に失敗しました サーバーが利用できません。しばらくしてからもう一度お試しください。 共有 @@ -812,6 +820,7 @@ 利息モード ステーキングは暗号資産で報酬を受け取る最も簡単な方法です。 %s 最大%s APYを獲得 + 別のネットワークまたはアカウント トークンを追加しました %sについて @@ -987,6 +996,10 @@ 操作を繰り返してください。カードは工場出荷時の設定にリセットされます。 アクティベーションに失敗しました トークンを追加する + + ウォレットに%dトークンがあります。続行するには、アドレスを同期してください。 + + ウォレットを同期してください バックアップ用のカードまたはリングを1つ追加しました。バックアップが完了すると、これ以上デバイスを追加することはできません。追加のカードまたはリングをお持ちの場合は、今のうちに追加してください。続行しますか? バックアップは一部完了しており、現在は中断できません。 パスフレーズは、リカバリーフレーズに単語やフレーズを追加することで、追加の保護を提供する任意のセキュリティ機能です。これにより、新しいウォレットアドレスのセットが作成されます。 @@ -1144,6 +1157,9 @@ 対応しているトークンが見つかりません このQRコードには認識できないパラメータが含まれています:%s。続行すると、一部の支払い情報が失われる可能性があります。 不明なパラメータ + クレジットカードまたは銀行口座 + アドレスまたはQRコードを共有してください + ポートフォリオ間で メモ不要 %3$sネットワーク上の%1$s ( %2$s ) %2$sネットワーク上の%1$s @@ -1507,6 +1523,7 @@ 24時間体制のサポートであらゆる問題に対応します。 いつもここに 複数の信頼できるプロバイダーが一箇所に集結。ウォレット内で様々な暗号資産を簡単に交換できます。 + Tangem内で暗号資産を直接交換\n追加の送金は不要\n取引所に資産を移す必要なし ぜひスワップしてください ウォレット内でスワップ 失敗も死角もありません。取引は常に保護されます。 @@ -1514,11 +1531,13 @@ 難攻不落の防御 主導権はあなたの手にあります 幅広いネットワークの中から、常に最適なプロバイダーと料金レートを選択します + TangemはDEXとCEXを含む複数のプロバイダーを比較し、最良レートを自動で選択します。別のプロバイダーを希望する場合は、手動で選択できます。 破格のレート 利用可能なベストレート 手間がかからず直感的に操作でき、数回タップするだけでトークンを交換できます。 主要ネットワークと数千種類のトークンに対応 ステーブルコイン同士のスワップは手数料0% とにかく便利 + 90以上のブロックチェーン\n16,000種類以上の暗号資産 プロバイダー経由のスワップ 資産 この金額には以下が含まれます:\n- サービスプロバイダーの手数料\n- 取引所からユーザーのアドレスに%s を送り返すためのネットワーク手数料。 @@ -1578,6 +1597,8 @@ カードの凍結解除に失敗しました。しばらくしてからもう一度お試しください。 カードの凍結が解除されました 出金 + 規制上の要件により無効化されましたが、出金は引き続き可能です。 + カードが無効化されました Root化された端末では使用できません 利用可能残高 メイン画面からKYCを非表示にする @@ -1586,7 +1607,7 @@ 入金オプション Googleウォレットに追加 カード番号 - PINを変更する + PINコード カードは支払いの準備が整いました。 PINコードを作成しました CVC @@ -1629,6 +1650,8 @@ 現在、出金ができません 現在の処理が完了するまでは、スワップや新しい出金を開始することはできません。 出金処理中です + %s以上の金額を設定してください + 限度額を設定できませんでした。もう一度お試しください。 変更 現在の利用限度額 1日の利用限度額を読み込めませんでした。もう一度お試しください。 @@ -1697,6 +1720,7 @@ サービスは一時的に利用できません 現在、データを表示できませんが、カードでのお支払いは引き続きご利用いただけます。 \nPINコードの設定 + カード無効化済み セッションの有効期限が切れました セッションを更新 日常の支払いにUSDCを利用 @@ -2201,6 +2225,7 @@ 手数料が差し引かれ、暗号資産が補充されます。 利息を継続的に生み出すには承認が必要です。 承認を確定する + 平均APY %1$s%% あなたの資金は現在Aaveプロトコルに預けられていますが、いつでも自由に管理できます。 %sはAaveに供給されています チャートを読み込めません・・ @@ -2274,6 +2299,7 @@ 利息は自動的に発生します 利息モード 利息モードの有効化 + 利息モード - %1$s%% APY 利息モード 利息モードコントラクトのデプロイ 利息モードが有効になりました diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index d382f6e81a..2aaf2c0bcc 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -274,14 +274,20 @@ Dia Dias + + %d dia atrás + há%d dias + Excluir Desativar Desabilitado + Desativar Desconectar Feito Editar Ativar Habilitado + Habilitar Erro taxa de recarga de rede Trocar @@ -327,6 +333,7 @@ OUTRO mês + Mais Taxa de rede O valor enviado será reduzido em %1$s (%2$s) para cobrir o nível de taxa selecionado @@ -372,6 +379,7 @@ Selecione a ação Vender Enviar + Enviar: Falha ao enviar a transação O servidor não está disponível. Tente novamente mais tarde. Compartilhar @@ -505,6 +513,9 @@ Endereços dinâmicos indisponíveis Não foi possível conectar-se ao provedor neste momento. Tente novamente mais tarde. Serviço indisponível. Por favor, tente novamente. + Foram encontrados fundos em endereços adicionais. Ative os Endereços Dinâmicos para acessá-los. + Fundos encontrados em endereços adicionais + Endereço dinâmico Melhores oportunidades Limpar filtro A lista está temporariamente vazia, pois está sendo atualizada. Volte daqui a pouco. @@ -775,6 +786,7 @@ Este recurso não está disponível para esta carteira. Adicionar APY %s + Preço de mercado Meu portfólio Mercado Ganhe com a Tangem @@ -787,6 +799,7 @@ Sem dados **Adicione ao seu portfólio** para começar a comprar, trocar ou receber este ativo. Em seu portfólio + Seu portfólio Pulso do mercado Ações rápidas Limpar tudo @@ -997,6 +1010,11 @@ Por favor, repita a operação. O cartão será redefinido para as configurações de fábrica. Erro de ativação Adicionar tokens + + Sua carteira contém %d token. Para continuar, sincronize seus endereços. + Sua carteira contém %d tokens. Para continuar, sincronize seus endereços. + + Sincronize sua carteira Você adicionou um cartão ou anel de backup. Depois que o backup for finalizado, você não poderá adicionar mais dispositivos. Se você tiver mais um cartão ou anel, adicione-o agora. Deseja continuar? O backup está parcialmente concluído e não pode ser encerrado agora. Uma frase-senha é um recurso de segurança opcional que adiciona uma palavra ou frase à sua frase de recuperação, criando um novo conjunto de endereços de carteira para proteção extra. @@ -1026,7 +1044,9 @@ Começando Já existe outra carteira associada ao cartão que você está tentando adicionar. Se você tiver fundos nessa carteira, faça o saque e, em seguida, redefina este cartão e adicione-o como reserva. Salve sua carteira + Utilizar biometria Criando um backup + Último passo Biometria Leia mais sobre a frase-semente. @@ -1522,13 +1542,21 @@ Sinta-se seguro com o suporte disponível 24 horas por dia, 7 dias por semana, para ajudá-lo com qualquer problema. Sempre aqui Diversos provedores confiáveis ​​em um só lugar — troque qualquer ativo facilmente em sua carteira. + Troque criptomoedas diretamente na Tangem\nSem transferências adicionais\nSem movimentação de fundos para corretoras Troque conosco + Troque o conteúdo da sua carteira. Sem erros, sem perdas de posse, sem pontos cegos — sua transação está sempre protegida. + As trocas são executadas por meio de provedores confiáveis. Suas chaves permanecem em sua carteira Tangem o tempo todo. Clara. Transparente. Autocustódia. Defesa Impenetrável + Você mantém o controle. Maximize o valor do seu investimento com tarifas provenientes de uma ampla rede de fornecedores confiáveis, escolhendo sempre a melhor opção. + A Tangem compara várias corretoras, tanto DEX quanto CEX. A melhor taxa é selecionada automaticamente. Prefere outra corretora? Você pode escolhê-la manualmente. Tarifas imbatíveis + Melhor tarifa disponível Sem complicações e intuitivo, permitindo que você troque tokens com apenas alguns toques + Troca entre as principais redes e milhares de tokens 0% Taxa em trocas de stablecoins Simplesmente conveniente + Mais de 90 blockchains | Mais de 16.000 ativos Trocar através do provedor Seus ativos O valor inclui:\n• taxa do provedor de serviços\n• taxa de rede para envio %s da central de distribuição de volta para o endereço do usuário. @@ -1596,7 +1624,7 @@ Opções de recarga Adicionar ao Google Wallet Número do cartão - Alterar PIN + Código PIN O cartão está totalmente pronto para pagamentos. Código PIN criado CVC @@ -1624,6 +1652,7 @@ Compartilhe seu endereço ou mostre o código QR. Problemas técnicos detectados. Tente novamente mais tarde ou entre em contato com o suporte. Receber indisponível agora + Substituir cartão Somente letras e números são permitidos. Caracteres inválidos Revelar @@ -1692,6 +1721,16 @@ Conta de pagamento A conta de pagamento não está sincronizada. PIN inválido: evite sequências ou repetições. + Substituir cartão + Isso gera um novo conjunto de dados do cartão. Seus dados antigos deixarão de funcionar. Você não pode desfazer essa ação. + Taxa de substituição + Informações sobre a taxa de substituição indisponíveis. + Substituindo seu cartão digital + Geralmente leva até 5 minutos. Em casos raros, até 48 horas. + Fundos insuficientes para substituir o cartão. + Deposite USDC na conta de pagamento para cobrir a taxa de emissão. + Não foi possível cobrir a taxa. + Precisa de um novo cartão? Estamos resolvendo um problema técnico. Por favor, tente novamente mais tarde. Serviço temporariamente indisponível Não foi possível exibir os detalhes. No entanto, os pagamentos com cartão ainda estão funcionando. @@ -2202,6 +2241,7 @@ A taxa será deduzida e seus ativos serão devolvidos. Para continuar gerando rendimento, é necessária aprovação. Confirmar aprovação + APY médio %1$s%% Seus fundos estão atualmente vinculados ao protocolo Aave, mas você pode gerenciá-los a qualquer momento. Seu %s é fornecido à Aave Não foi possível carregar o gráfico... @@ -2275,6 +2315,7 @@ Os juros acumulam-se automaticamente. Modo de rendimento Ativação do modo de rendimento + Modo de rendimento • %1$s%% APY Modo de rendimento Implementação do contrato do modo de rendimento Modo Yield ativado diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 5800ceb0fe..91dd3fa436 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1636,9 +1636,9 @@ Карта Tangem Pay Пополнить Способы пополнения - Добавить в Google кошелек + Добавить в Google Wallet Номер - Сменить ПИН + ПИН-код Карта готова к покупкам ПИН-код установлен CVC @@ -1666,6 +1666,7 @@ Скопируйте свой адрес или покажите QR Техническая ошибка. Попробуйте позже или обратитесь в поддержку. Пополнение недоступно + Перевыпустить карту Можно вводить только буквы и цифры Недопустимые символы Показать diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index be2d491475..fee0598cb0 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1570,7 +1570,7 @@ Поповнити рахунок Варіанти поповнення Номер картки - Змінити PIN-код + ПІН-код Картка готова до оплат. PIN-код створено CVC diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 775c2091a0..b93bb9d6e6 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -275,11 +275,13 @@ 删除 禁用 已禁用 + 禁用 断开 完成 编辑 启用 已启用 + 启用 错误 充值网络费 兑换 @@ -323,6 +325,7 @@ 分钟之前 + 更多的 网络费用 汇款金额将减少 %1$s (%2$s)以支付所选费用等级 @@ -367,6 +370,7 @@ 选择操作 出售 发送 + 发送: 交易发送失败 服务器不可用,请稍后再试。 分享 @@ -990,6 +994,10 @@ 请重复此操作。该卡将恢复出厂设置。 激活错误 添加代币 + + 您的钱包包含%d种代币,要继续,请同步您的地址。 + + 同步您的钱包 您已添加了一个备份卡或指环。一旦备份完成,就不能再添加更多设备。如果您还有一张卡或指环,请现在添加。要继续吗? 备份仅部分完成,现在无法退出。 密码短语是一项可选的安全功能,它会在您的恢复短语中添加一个单词或短语,从而创建一组新的钱包地址以获得额外的保护。 @@ -1147,6 +1155,9 @@ 未找到支持的代币 此二维码包含无法识别的参数: %s如果您继续操作,部分支付信息可能会丢失。 未知参数 + 信用卡或银行账户 + 分享您的地址或二维码 + 在您的投资组合之间 无需备忘录 %1$s (%2$s) 在 %3$s 网络 %1$s 在 %2$s 网络 @@ -1592,7 +1603,7 @@ 充值选项 添加到 Google 钱包 卡号 - 更改PIN码 + PIN码 该卡已完全准备好用于支付。 已创建 PIN 码 CVC @@ -1635,6 +1646,8 @@ 目前无法提款 当前提款交易完成之前,您无法发起新的提款交易或互换交易。 提款进行中 + 从 %s设定一个限额 + 我们无法设置限额,请稍后再试。 改变 当前限额 我们无法加载您的每日限额。请稍后再试。 @@ -2207,6 +2220,7 @@ 费用将被扣除,您的资产将被重新提供。 要继续产生收益,需要获得批准。 确认批准 + 平均年收益率 %1$s%% 您的资金目前已提供给 Aave 协议,但您可以随时对其进行管理。 你的 %s 提供给 Aave 无法加载图表... @@ -2280,6 +2294,7 @@ 利息自动累积 收益模式 启用收益模式 + 收益模式 - %1$s%% APY 收益模式 收益模式合约部署 已启用收益模式 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 7c4a7e1cd7..351d58a26f 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -219,6 +219,7 @@ Accounts Activate Add + Add funds Add to portfolio Add token Add tokens @@ -830,6 +831,7 @@ Yield Mode Staking is the easiest way to receive rewards on your crypto. %s Earn up to %s APY + in another network or account Token Added About %s @@ -1620,6 +1622,8 @@ Failed to unfreeze the card. Try again later. Your card is unfrozen. Withdrawal + This was done due to regulatory requirements. Anyway withdrawals are still available. + Your card was deactivated Unable to use on rooted device Available balance Hide KYC from main screen @@ -1628,7 +1632,7 @@ Top-up options Add to Google Wallet Card Number - Change PIN + PIN code The card is fully ready for payments. PIN code created CVC @@ -1671,6 +1675,8 @@ Withdraw unavailable now You can\'t initiate swap or new withdrawal till the current one is finished Withdrawal in progress + Set a limit from %s + We couldn’t set the limit. Please try again. Change Current limit We couldn\'t load your daily limit. Please try again. @@ -1679,8 +1685,6 @@ Daily limit is set Daily limit Card settings - Set a limit from %1$s - We couldn’t set the limit. Please try again. Change PIN-code Come back to the app if you forget it. Set a limit from %s to %s @@ -1741,6 +1745,7 @@ Service temporarily unavailable Unable to display details. However, card payments are still working. Set \nPIN code + Card deactivated Session expired Renew session Use USDC for everyday payments diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 122418a100..83bb5cdb69 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -7,7 +7,7 @@ tangemBlockchainSdk = "develop-1498" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "develop-602" +tangemCardSdk = "develop-611" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 1bd07c862651e2989fd43d8e635d91040a4fb313 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 09:04:22 +0000 Subject: [PATCH 118/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 94aa5668ec..83bb5cdb69 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1494" +tangemBlockchainSdk = "develop-1498" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-603" +tangemCardSdk = "develop-611" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 5315e044875bf1907687daf59c6b3a471e05c61b Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 13:07:24 +0400 Subject: [PATCH 119/206] Updated on 2026-08-14 --- .../markets/DefaultMarketsTokenRepository.kt | 2 +- .../tangem/domain/markets/RawMarketToken.kt | 12 ++++ .../markets/GetTokenMarketCryptoCurrency.kt | 2 +- .../repositories/MarketsTokenRepository.kt | 2 +- .../addtoportfolio/AddToPortfolioManager.kt | 43 ++++++++++--- .../impl/addtoportfolio/AddTokenComponent.kt | 2 +- .../DefaultAddToPortfolioComponent.kt | 58 +++++++++--------- ...tAddToPortfolioPreselectedDataComponent.kt | 3 +- .../addtoportfolio/TokenActionsComponent.kt | 2 +- .../analytics/PortfolioAnalyticsEvent.kt | 27 +++++--- .../converter/AvailableToAddDataConverter.kt | 6 +- .../AddToPortfolioInitialSelectionResolver.kt | 6 +- .../model/AddToPortfolioModel.kt | 60 +++++++++--------- .../AddToPortfolioPreselectedDataModel.kt | 13 +--- .../addtoportfolio/model/AddTokenModel.kt | 10 +-- .../addtoportfolio/model/TokenActionsModel.kt | 3 +- .../model/TokenActionsUiBuilder.kt | 10 --- .../ui/DefaultAddToPortfolioManager.kt | 45 ++++++++------ ...ToPortfolioInitialSelectionResolverTest.kt | 4 +- .../DefaultMarketsTokenDetailsComponent.kt | 5 +- .../details/MarketsTokenDetailsModel.kt | 61 ++++++++----------- 21 files changed, 199 insertions(+), 177 deletions(-) create mode 100644 domain/markets/models/src/main/kotlin/com/tangem/domain/markets/RawMarketToken.kt diff --git a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt index 799793848c..95992352f7 100644 --- a/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt +++ b/data/markets/src/main/java/com/tangem/data/markets/DefaultMarketsTokenRepository.kt @@ -247,7 +247,7 @@ internal class DefaultMarketsTokenRepository( override suspend fun createCryptoCurrency( userWalletId: UserWalletId, - token: TokenMarketParams, + token: RawMarketToken, network: TokenMarketInfo.Network, accountIndex: DerivationIndex?, ): CryptoCurrency? { diff --git a/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/RawMarketToken.kt b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/RawMarketToken.kt new file mode 100644 index 0000000000..26a5c74947 --- /dev/null +++ b/domain/markets/models/src/main/kotlin/com/tangem/domain/markets/RawMarketToken.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.markets + +import com.tangem.domain.models.currency.CryptoCurrency + +/** + * minimal token info for add-to-portfolio flow + */ +data class RawMarketToken( + val id: CryptoCurrency.RawID, + val name: String, + val symbol: String, +) \ No newline at end of file diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt index 4cdd14d937..46eee09ba2 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/GetTokenMarketCryptoCurrency.kt @@ -10,7 +10,7 @@ class GetTokenMarketCryptoCurrency( ) { suspend operator fun invoke( userWalletId: UserWalletId, - tokenMarketParams: TokenMarketParams, + tokenMarketParams: RawMarketToken, network: TokenMarketInfo.Network, accountIndex: DerivationIndex, ): CryptoCurrency? { diff --git a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt index acf2b29ff1..16d5ce98ea 100644 --- a/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt +++ b/domain/markets/src/main/java/com/tangem/domain/markets/repositories/MarketsTokenRepository.kt @@ -42,7 +42,7 @@ interface MarketsTokenRepository { suspend fun createCryptoCurrency( userWalletId: UserWalletId, - token: TokenMarketParams, + token: RawMarketToken, network: TokenMarketInfo.Network, accountIndex: DerivationIndex? = null, ): CryptoCurrency? diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt index 70e41a3756..5b53e9abc2 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt @@ -1,13 +1,13 @@ package com.tangem.features.commonfeatures.api.addtoportfolio +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharedFlow @@ -21,11 +21,22 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal { val onSuccessAdded: Channel val onAddedTokenClick: Channel - val portfolioFetcher: PortfolioFetcher val state: StateFlow + /** + * default is [LaunchMode.DirectAdd] + */ + fun updateLaunchMode(launchMode: LaunchMode) + fun setTokenNetworks(networks: List) - fun setTokenParams(token: TokenMarketParams) + fun setTokenParams(token: RawMarketToken) + fun setTokenParams(token: TokenMarketParams) = setTokenParams( + RawMarketToken( + id = token.id, + name = token.name, + symbol = token.symbol, + ), + ) sealed interface State { data object Loading : State @@ -36,7 +47,15 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal { } @Serializable - data class AnalyticsParams(val source: String?) + data class AnalyticsParams( + val source: String?, + val category: String = CategoryDefault, + ) { + companion object { + const val CategoryDefault = "Markets / Chart" + const val CategoryEarn = "Earn" + } + } interface Factory { fun create(scope: CoroutineScope, settings: Settings, analyticsParams: AnalyticsParams): AddToPortfolioManager @@ -44,7 +63,8 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal { sealed interface LaunchMode { data object DirectAdd : LaunchMode - data class ViaUserPortfolio(val rawCurrencyId: CryptoCurrency.RawID) : LaunchMode + data object Preselected : LaunchMode + data object ViaUserPortfolio : LaunchMode } /** @@ -52,7 +72,6 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal { */ data class Settings( val shouldSkipTokenActionsScreen: Boolean = false, - val launchMode: LaunchMode = LaunchMode.DirectAdd, ) { companion object { val DefaultMarket = Settings(shouldSkipTokenActionsScreen = false) @@ -64,7 +83,11 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal { * Mutable parameters * Updates may trigger reload [State] */ - data class Params(val networks: List, val token: TokenMarketParams) + data class Params( + val networks: List, + val token: RawMarketToken, + val launchMode: LaunchMode, + ) data class Result( val wallet: UserWallet, @@ -80,8 +103,10 @@ interface AddToPortfolioManagerInternal { val paramsFlow: SharedFlow val settings: AddToPortfolioManager.Settings val analyticsParams: AnalyticsParams + val portfolioFetcher: PortfolioFetcher - suspend fun token(): TokenMarketParams = paramsFlow.first().token + suspend fun params(): AddToPortfolioManager.Params = paramsFlow.first() + suspend fun token(): RawMarketToken = params().token fun onDismiss() fun onSuccessAdded(result: AddToPortfolioManager.Result) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt index 59f8f27bd1..0530a497f3 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddTokenComponent.kt @@ -45,7 +45,7 @@ internal class AddTokenComponent @AssistedInject constructor( } data class Params( - val eventBuilder: Flow, + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, val selectedPortfolio: Flow, val selectedNetwork: Flow, val callbacks: Callbacks, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt index 214ba365f5..189c551c95 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt @@ -12,7 +12,6 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes @@ -37,29 +36,33 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create( context = child("portfolioSelectorComponent"), params = PortfolioSelectorComponent.Params( - portfolioFetcher = model.portfolioFetcher, + portfolioFetcher = model.addToPortfolioManager.portfolioFetcher, controller = model.portfolioSelectorController, ), ) - private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( - context = child("addTokenComponent"), - params = AddTokenComponent.Params( - eventBuilder = model.eventBuilder, - callbacks = model, - selectedPortfolio = model.selectedPortfolio, - selectedNetwork = model.selectedNetwork, - ), - ) + private val addTokenComponent: AddTokenComponent by lazy { + addTokenComponentFactory.create( + context = child("addTokenComponent"), + params = AddTokenComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + selectedPortfolio = model.selectedPortfolio, + selectedNetwork = model.selectedNetwork, + ), + ) + } - private val tokenActionsComponent: TokenActionsComponent = tokenActionsComponentFactory.create( - context = child("tokenActionsComponent"), - params = TokenActionsComponent.Params( - eventBuilder = model.eventBuilder, - callbacks = model, - data = model.tokenActionsData, - ), - ) + private val tokenActionsComponent: TokenActionsComponent by lazy { + tokenActionsComponentFactory.create( + context = child("tokenActionsComponent"), + params = TokenActionsComponent.Params( + eventBuilder = model.eventBuilder, + callbacks = model, + data = model.tokenActionsData, + ), + ) + } private val childStack = childStack( key = "addToPortfolioStack", @@ -106,16 +109,13 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( } private fun createUserPortfolioComponent(componentContext: ComponentContext): ComposableContentComponent { - return when (model.addToPortfolioManager.settings.launchMode) { - AddToPortfolioManager.LaunchMode.DirectAdd -> ComposableContentComponent.EMPTY - is AddToPortfolioManager.LaunchMode.ViaUserPortfolio -> userPortfolioComponentFactory.create( - context = childByContext(componentContext), - params = UserPortfolioComponent.Params( - uiState = model.userPortfolioStateController.uiState, - callbacks = model, - ), - ) - } + return userPortfolioComponentFactory.create( + context = childByContext(componentContext), + params = UserPortfolioComponent.Params( + uiState = model.userPortfolioStateController.uiState, + callbacks = model, + ), + ) } @AssistedFactory diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt index bb72f99975..103f0d4ee0 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt @@ -16,7 +16,6 @@ import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfol import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.flowOf internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject constructor( @Assisted context: AppComponentContext, @@ -38,7 +37,7 @@ internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject con private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( context = child("addTokenComponent"), params = AddTokenComponent.Params( - eventBuilder = flowOf(model.eventBuilder), + eventBuilder = model.eventBuilder, callbacks = model, selectedPortfolio = model.selectedPortfolio, selectedNetwork = model.selectedNetwork, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt index c4bce69e42..30cd1227fc 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/TokenActionsComponent.kt @@ -72,7 +72,7 @@ internal class TokenActionsComponent @AssistedInject constructor( ) data class Params( - val eventBuilder: Flow, + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder, val data: Flow, val callbacks: Callbacks, ) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt index 6f72186a1b..925cf4831b 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/analytics/PortfolioAnalyticsEvent.kt @@ -2,34 +2,41 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.analytics import com.tangem.common.ui.markets.action.TokenActionsBSContentUM import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam -// todo swap unify with EarnAnalyticsEvent, AddToPortfolioFlow internal class PortfolioAnalyticsEvent( event: String, params: Map = emptyMap(), -) : AnalyticsEvent(category = "Markets / Chart", event = event, params = params) { + category: String, +) : AnalyticsEvent(category = category, event = event, params = params) { data class EventBuilder( val tokenSymbol: String, val source: String?, + val category: String, ) { fun popupToChooseAccount() = PortfolioAnalyticsEvent( event = "Choose Account Opened", + category = category, params = buildMap { - if (source != null) put("Source", source) + if (source != null) put(AnalyticsParam.SOURCE, source) }, ) - fun popupToConfirm() = PortfolioAnalyticsEvent( + fun popupToConfirm(blockchain: String) = PortfolioAnalyticsEvent( event = "Add Token Screen Opened", + category = category, params = buildMap { - if (source != null) put("Source", source) + put(AnalyticsParam.TOKEN_PARAM, tokenSymbol) + put(AnalyticsParam.BLOCKCHAIN, blockchain) + if (source != null) put(AnalyticsParam.SOURCE, source) }, ) fun addToNotMainAccount() = PortfolioAnalyticsEvent( event = "Button - Add To Account", + category = category, params = buildMap { if (source != null) put("Source", source) }, @@ -37,6 +44,7 @@ internal class PortfolioAnalyticsEvent( fun addButtonClick() = PortfolioAnalyticsEvent( event = "Button - Add Token", + category = category, params = buildMap { if (source != null) put("Source", source) }, @@ -44,6 +52,7 @@ internal class PortfolioAnalyticsEvent( fun addToPortfolioWalletChanged() = PortfolioAnalyticsEvent( event = "Wallet Selected", + category = category, params = buildMap { if (source != null) put("Source", source) }, @@ -51,6 +60,7 @@ internal class PortfolioAnalyticsEvent( fun addToPortfolioContinue(blockchainNames: List) = PortfolioAnalyticsEvent( event = "Token Network Selected", + category = category, params = buildMap { put("Count", blockchainNames.size.toString()) put("Token", tokenSymbol) @@ -61,9 +71,10 @@ internal class PortfolioAnalyticsEvent( fun tokenAdded(blockchainName: String) = PortfolioAnalyticsEvent( event = "Token Added", + category = category, params = buildMap { - put("Token", tokenSymbol) - put("Blockchain", blockchainName) + put(AnalyticsParam.TOKEN_PARAM, tokenSymbol) + put(AnalyticsParam.BLOCKCHAIN, blockchainName) if (source != null) put("Source", source) }, ) @@ -76,6 +87,7 @@ internal class PortfolioAnalyticsEvent( TokenActionsBSContentUM.Action.Stake -> "Popup Get token - Button Stake" else -> "error" }, + category = category, params = buildMap { if (source != null) put("Source", source) }, @@ -83,6 +95,7 @@ internal class PortfolioAnalyticsEvent( fun getTokenLater() = PortfolioAnalyticsEvent( event = "Popup Get token - Button Later", + category = category, params = buildMap { if (source != null) put("Source", source) }, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt index fd5664787d..ed829bd5c3 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/converter/AvailableToAddDataConverter.kt @@ -3,8 +3,8 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.converter import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.markets.FilterAvailableNetworksForWalletUseCase import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.AccountStatus @@ -27,7 +27,7 @@ internal class AvailableToAddDataConverter @Inject constructor( suspend fun convert( balances: Map, availableNetworks: Set, - marketParams: TokenMarketParams, + marketParams: RawMarketToken, ): AvailableToAddData { suspend fun AccountStatus.CryptoPortfolio.getAvailableToAddAccount(wallet: UserWallet): AvailableToAddAccount? { val currencies = availableNetworks @@ -103,7 +103,7 @@ internal class AvailableToAddDataConverter @Inject constructor( private suspend fun createCryptoCurrency( userWallet: UserWallet, network: TokenMarketInfo.Network, - marketParams: TokenMarketParams, + marketParams: RawMarketToken, account: Account.CryptoPortfolio, ): CryptoCurrency? { return getTokenMarketCryptoCurrency( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt index 8db0647303..dd8d751766 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt @@ -2,8 +2,8 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.model import arrow.core.getOrElse import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase @@ -21,7 +21,7 @@ internal class AddToPortfolioInitialSelectionResolver @Inject constructor( availableToAddData: AvailableToAddData, orderedNetworks: List, selectedWallet: UserWallet?, - tokenParams: TokenMarketParams, + tokenParams: RawMarketToken, accountToAdd: AvailableToAddAccount? = null, ): InitialSelection? { if (availableToAddData.availableToAddWallets.isEmpty()) return null @@ -85,7 +85,7 @@ internal class AddToPortfolioInitialSelectionResolver @Inject constructor( userWallet: UserWallet, account: AvailableToAddAccount, orderedNetworks: List, - tokenParams: TokenMarketParams, + tokenParams: RawMarketToken, ): TokenMarketInfo.Network? { val availableOrdered = orderedNetworks.filter { candidate -> account.availableToAddNetworks.any { it.networkId == candidate.networkId } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 7754a0d1ea..9c2305f233 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -17,8 +17,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -26,7 +26,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.features.commonfeatures.api.addtoportfolio.* -import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent @@ -78,8 +77,18 @@ internal class AddToPortfolioModel @Inject constructor( val tokenActionsData: MutableSharedFlow = replayMutableSharedFlow() val addToPortfolioManager: AddToPortfolioManager = params.addToPortfolioManager - val portfolioFetcher: PortfolioFetcher = addToPortfolioManager.portfolioFetcher - val eventBuilder: MutableSharedFlow = replayMutableSharedFlow() + + val paramsSnapshot: AddToPortfolioManager.Params by lazy { + addToPortfolioManager.paramsFlow.replayCache.first() + } + val eventBuilder: PortfolioAnalyticsEvent.EventBuilder by lazy { + val tokenMarketParams = paramsSnapshot.token + PortfolioAnalyticsEvent.EventBuilder( + tokenSymbol = tokenMarketParams.symbol, + source = addToPortfolioManager.analyticsParams.source, + category = addToPortfolioManager.analyticsParams.category, + ) + } val userPortfolioStateController = userPortfolioStateControllerFactory.create( modelScope = modelScope, @@ -120,12 +129,6 @@ internal class AddToPortfolioModel @Inject constructor( .distinctUntilChanged() .stateIn(this) val isAccountMode = portfolioSelectorController.isAccountModeSync() - val tokenMarketParams = addToPortfolioManager.paramsFlow.first().token - val eb = PortfolioAnalyticsEvent.EventBuilder( - tokenSymbol = tokenMarketParams.symbol, - source = addToPortfolioManager.analyticsParams.source, - ) - eventBuilder.tryEmit(eb) // use snapshot data, looks like we don’t need to remap at runtime val data = featureDataFlow.value @@ -172,11 +175,12 @@ internal class AddToPortfolioModel @Inject constructor( ) // suspend until all required data is selected - allRequireForAdd.first() + val firstPair = allRequireForAdd.first() // line of navigation to AddToken screen is finished; cancel the job, select a new root screen firstPartOfNavigation.cancel() - analyticsEventHandler.send(event = eventBuilder.first().popupToConfirm()) + val selectedNetworkName = firstPair.first.cryptoCurrency.network.name + analyticsEventHandler.send(event = eventBuilder.popupToConfirm(selectedNetworkName)) navigation.replaceAll(AddToPortfolioRoutes.AddToken) var middleNavigationJob: Job? = null @@ -204,6 +208,10 @@ internal class AddToPortfolioModel @Inject constructor( val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() middleNavigationJob?.cancel() val selectedPortfolio = selectedPortfolio.first() + analyticsEventHandler.send(eventBuilder.tokenAdded(addedToken.currency.network.name)) + if (!selectedPortfolio.account.account.account.isMainAccount) { + analyticsEventHandler.send(eventBuilder.addToNotMainAccount()) + } val result = AddToPortfolioManager.Result( wallet = selectedPortfolio.userWallet, account = selectedPortfolio.account.account, @@ -225,6 +233,7 @@ internal class AddToPortfolioModel @Inject constructor( } callbackDelegate.onLaterClick.receiveAsFlow().first() + analyticsEventHandler.send(eventBuilder.getTokenLater()) finishSuccessFlow(result) } .catch { throwable -> @@ -247,29 +256,22 @@ internal class AddToPortfolioModel @Inject constructor( channel.close() } - val tokenMarketParams = addToPortfolioManager.paramsFlow.first().token - val eb = PortfolioAnalyticsEvent.EventBuilder( - tokenSymbol = tokenMarketParams.symbol, - source = addToPortfolioManager.analyticsParams.source, - ) - eventBuilder.tryEmit(eb) + val tokenMarketParams = paramsSnapshot.token - val launchMode = addToPortfolioManager.settings.launchMode + val launchMode = paramsSnapshot.launchMode val initialData = featureData .filterIsInstance() .map { it.availableToAddData } .first() if (launchMode is AddToPortfolioManager.LaunchMode.ViaUserPortfolio && - initialData.hasAnyAddedCurrency(launchMode.rawCurrencyId) + initialData.hasAnyAddedCurrency(tokenMarketParams.id) ) { // suspend, must prepare UM before navigate to UserPortfolio - userPortfolioStateController.updateAndWaitNotNullState(initialData, launchMode.rawCurrencyId) + userPortfolioStateController.updateAndWaitNotNullState(initialData, tokenMarketParams.id) navigation.replaceAll(AddToPortfolioRoutes.UserPortfolio) callbackDelegate.onContinueFromUserPortfolio.receiveAsFlow().first() } - - val paramsSnapshot = addToPortfolioManager.paramsFlow.first() val selection = selectionResolver.resolve( availableToAddData = initialData, orderedNetworks = paramsSnapshot.networks, @@ -297,7 +299,8 @@ internal class AddToPortfolioModel @Inject constructor( selectedPortfolio.emit(firstSelectedPortfolio) selectedNetwork.emit(firstSelectedNetwork) - analyticsEventHandler.send(event = eventBuilder.first().popupToConfirm()) + val selectedNetworkName = firstSelectedNetwork.cryptoCurrency.network.name + analyticsEventHandler.send(event = eventBuilder.popupToConfirm(selectedNetworkName)) navigation.replaceAll(AddToPortfolioRoutes.AddToken) var middleNavigationJob: Job? = null @@ -349,6 +352,7 @@ internal class AddToPortfolioModel @Inject constructor( .launchIn(this) callbackDelegate.onLaterClick.receiveAsFlow().first() + analyticsEventHandler.send(eventBuilder.getTokenLater()) finishSuccessFlow(result) } .catch { throwable -> @@ -358,9 +362,9 @@ internal class AddToPortfolioModel @Inject constructor( .launchIn(modelScope) } - private suspend fun logAccountSelector(isAccountMode: Boolean) { + private fun logAccountSelector(isAccountMode: Boolean) { if (isAccountMode) { - analyticsEventHandler.send(eventBuilder.first().popupToChooseAccount()) + analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) } } @@ -402,7 +406,7 @@ internal class AddToPortfolioModel @Inject constructor( private fun changePortfolioNavigationNewFlow( data: AvailableToAddData, orderedNetworks: List, - tokenParams: TokenMarketParams, + tokenParams: RawMarketToken, ): Flow { return setupPortfolioFlow(data) // drop first selected portfolio or any selected before @@ -473,7 +477,7 @@ internal class AddToPortfolioModel @Inject constructor( data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null val availableToAddAccount = availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null - if (!isAccountMode) analyticsEventHandler.send(eventBuilder.first().addToPortfolioWalletChanged()) + if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) SelectedPortfolio( isAccountMode = isAccountMode, userWallet = availableToAddWallets.userWallet, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt index a2a388b7b2..d1e84ecc71 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt @@ -12,8 +12,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.ToastMessage import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrency @@ -31,7 +31,6 @@ import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* -import java.math.BigDecimal import javax.inject.Inject import kotlin.collections.mapNotNull @@ -66,6 +65,7 @@ internal class AddToPortfolioPreselectedDataModel @Inject constructor( val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( tokenSymbol = params.tokenToAdd.symbol, source = AnalyticsParam.ScreensSources.Markets.value, + category = AnalyticsParam.ScreensSources.Markets.value, ) init { @@ -149,17 +149,10 @@ internal class AddToPortfolioPreselectedDataModel @Inject constructor( } private fun minimalTokenMarketParams() = with(params.tokenToAdd) { - TokenMarketParams( + RawMarketToken( id = id, name = name, symbol = symbol, - tokenQuotes = TokenMarketParams.Quotes( - currentPrice = BigDecimal.ZERO, - h24Percent = null, - weekPercent = null, - monthPercent = null, - ), - imageUrl = null, ) } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt index ad1eee5632..7b7facbc0e 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddTokenModel.kt @@ -85,7 +85,7 @@ internal class AddTokenModel @Inject constructor( uiState.value = um.toggleProgress(true) val blockchainNames = listOf(selectedNetwork.selectedNetwork) .mapNotNull { BlockchainUtils.getNetworkInfo(it.networkId)?.name } - val analyticsEventBuilder = params.eventBuilder.first() + val analyticsEventBuilder = params.eventBuilder analyticsEventHandler.send(analyticsEventBuilder.addToPortfolioContinue(blockchainNames)) analyticsEventHandler.send(analyticsEventBuilder.addButtonClick()) @@ -105,14 +105,6 @@ internal class AddTokenModel @Inject constructor( if (status == null) { processError(error = null) } else { - if (!account.isMainAccount) { - analyticsEventHandler.send(analyticsEventBuilder.addToNotMainAccount()) - } - - analyticsEventHandler.send( - event = analyticsEventBuilder.tokenAdded(status.status.currency.network.name), - ) - params.callbacks.onTokenAdded(status.status) } uiState.value = um.toggleProgress(false) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt index 7d2aaadc8c..9619c92763 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsModel.kt @@ -64,7 +64,6 @@ internal class TokenActionsModel @Inject constructor( uiBuilder.build( cryptoCurrencyData = cryptoCurrencyData, tokenActionsHandler = tokenActionsHandler, - eventBuilder = analyticsEventBuilder.first(), appCurrency = currentAppCurrency.value, isBalanceHidden = isBalanceHidden, ) @@ -76,7 +75,7 @@ internal class TokenActionsModel @Inject constructor( ) private fun handledQuickAction(handledAction: TokenActionsHandler.HandledQuickAction) = modelScope.launch { - val event = analyticsEventBuilder.first().getTokenActionClick(actionUM = handledAction.action) + val event = analyticsEventBuilder.getTokenActionClick(actionUM = handledAction.action) analyticsEventHandler.send(event) val isReceive = handledAction.action == TokenActionsBSContentUM.Action.Receive if (!isReceive) return@launch diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt index f461212f82..d1f169a0ae 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt @@ -8,7 +8,6 @@ import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToI import com.tangem.common.ui.markets.action.CryptoCurrencyData import com.tangem.common.ui.markets.action.QuickActionsConverter.quickActions import com.tangem.common.ui.markets.action.TokenActionsHandler -import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.DesignFeatureToggles @@ -25,7 +24,6 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.commonfeatures.impl.addtoportfolio.TokenActionsComponent -import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM import java.math.BigDecimal import javax.inject.Inject @@ -33,7 +31,6 @@ import javax.inject.Inject @ModelScoped internal class TokenActionsUiBuilder @Inject constructor( paramsContainer: ParamsContainer, - private val analyticsEventHandler: AnalyticsEventHandler, private val designFeatureToggles: DesignFeatureToggles, ) { private val params = paramsContainer.require() @@ -41,7 +38,6 @@ internal class TokenActionsUiBuilder @Inject constructor( fun build( cryptoCurrencyData: CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler, - eventBuilder: PortfolioAnalyticsEvent.EventBuilder, appCurrency: AppCurrency, isBalanceHidden: Boolean, ): TokenActionsUM { @@ -49,7 +45,6 @@ internal class TokenActionsUiBuilder @Inject constructor( buildV2( cryptoCurrencyData = cryptoCurrencyData, tokenActionsHandler = tokenActionsHandler, - eventBuilder = eventBuilder, appCurrency = appCurrency, isBalanceHidden = isBalanceHidden, ) @@ -57,7 +52,6 @@ internal class TokenActionsUiBuilder @Inject constructor( buildV1( cryptoCurrencyData = cryptoCurrencyData, tokenActionsHandler = tokenActionsHandler, - eventBuilder = eventBuilder, ) } } @@ -65,7 +59,6 @@ internal class TokenActionsUiBuilder @Inject constructor( private fun buildV1( cryptoCurrencyData: CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler, - eventBuilder: PortfolioAnalyticsEvent.EventBuilder, ): TokenActionsUM { val status = cryptoCurrencyData.status val tokenUM = TokenItemState.Content( @@ -86,7 +79,6 @@ internal class TokenActionsUiBuilder @Inject constructor( isRedesignEnabled = false, ), onLaterClick = { - analyticsEventHandler.send(eventBuilder.getTokenLater()) params.callbacks.onLaterClick() }, ) @@ -95,7 +87,6 @@ internal class TokenActionsUiBuilder @Inject constructor( private fun buildV2( cryptoCurrencyData: CryptoCurrencyData, tokenActionsHandler: TokenActionsHandler, - eventBuilder: PortfolioAnalyticsEvent.EventBuilder, appCurrency: AppCurrency, isBalanceHidden: Boolean, ): TokenActionsUM { @@ -118,7 +109,6 @@ internal class TokenActionsUiBuilder @Inject constructor( isRedesignEnabled = true, ), onLaterClick = { - analyticsEventHandler.send(eventBuilder.getTokenLater()) params.callbacks.onLaterClick() }, isBalancesHidden = isBalanceHidden, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt index 7eb7d23e9c..7b833e9e32 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/DefaultAddToPortfolioManager.kt @@ -1,11 +1,10 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.ui +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams -import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.Settings +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.* +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.commonfeatures.impl.addtoportfolio.converter.AvailableToAddDataConverter import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted @@ -25,8 +24,8 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( ) : AddToPortfolioManager { override val onDismiss: Channel = Channel() - override val onSuccessAdded: Channel = Channel() - override val onAddedTokenClick: Channel = Channel() + override val onSuccessAdded: Channel = Channel() + override val onAddedTokenClick: Channel = Channel() override val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), @@ -37,17 +36,17 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( override val paramsFlow = internalParamsFlow .transform { internalParams -> - val fullParams = AddToPortfolioManager.Params( + val fullParams = Params( networks = internalParams.networks ?: return@transform, token = internalParams.token ?: return@transform, + launchMode = internalParams.launchMode, ) emit(fullParams) } .distinctUntilChanged() .shareIn(scope = scope, started = SharingStarted.Eagerly, replay = 1) - override val state: MutableStateFlow = - MutableStateFlow(AddToPortfolioManager.State.Loading) + override val state: MutableStateFlow = MutableStateFlow(State.Loading) init { buildFlow() @@ -60,11 +59,11 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( onDismiss.trySend(Unit) } - override fun onSuccessAdded(result: AddToPortfolioManager.Result) { + override fun onSuccessAdded(result: Result) { onSuccessAdded.trySend(result) } - override fun onAddedTokenClick(result: AddToPortfolioManager.Result) { + override fun onAddedTokenClick(result: Result) { onAddedTokenClick.trySend(result) } @@ -72,23 +71,32 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( updateInternal(networks = networks) } - override fun setTokenParams(token: TokenMarketParams) { + override fun setTokenParams(token: RawMarketToken) { updateInternal(token = token) } - private fun updateInternal(networks: List? = null, token: TokenMarketParams? = null) { + override fun updateLaunchMode(launchMode: LaunchMode) { + updateInternal(launchMode = launchMode) + } + + private fun updateInternal( + networks: List? = null, + token: RawMarketToken? = null, + launchMode: LaunchMode? = null, + ) { internalParamsFlow.update { prev -> val newParams = ParamsInternal( networks = networks ?: prev.networks, token = token ?: prev.token, + launchMode = launchMode ?: prev.launchMode, ) - val shouldReload = prev.networks != newParams.networks || prev.token != newParams.token - if (shouldReload) state.update { AddToPortfolioManager.State.Loading } + val shouldReload = newParams != prev + if (shouldReload) state.update { State.Loading } return@update newParams } } - private fun buildFlow(): Flow = combine( + private fun buildFlow(): Flow = combine( flow = portfolioFetcher.data.map { it.balances }.distinctUntilChanged(), flow2 = paramsFlow, ) { balances, (availableNetworks, token) -> @@ -97,7 +105,7 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( availableNetworks = availableNetworks.toSet(), marketParams = token, ) - AddToPortfolioManager.State.Ready(data) + State.Ready(data) } @AssistedFactory @@ -110,7 +118,8 @@ internal class DefaultAddToPortfolioManager @AssistedInject constructor( } private data class ParamsInternal( + val launchMode: LaunchMode = LaunchMode.DirectAdd, val networks: List? = null, - val token: TokenMarketParams? = null, + val token: RawMarketToken? = null, ) } \ No newline at end of file diff --git a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt index 34c14e1224..3e6307f8ed 100644 --- a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt +++ b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt @@ -4,8 +4,8 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth import com.tangem.domain.markets.GetTokenMarketCryptoCurrency +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.account.DerivationIndex import com.tangem.domain.models.currency.CryptoCurrency @@ -31,7 +31,7 @@ class AddToPortfolioInitialSelectionResolverTest { private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency = mockk() private val networkHasDerivationUseCase: NetworkHasDerivationUseCase = mockk() - private val tokenParams: TokenMarketParams = mockk() + private val tokenParams: RawMarketToken = mockk() private lateinit var resolver: AddToPortfolioInitialSelectionResolver diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt index 0e521d801a..cb37e92734 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/DefaultMarketsTokenDetailsComponent.kt @@ -99,7 +99,7 @@ internal class DefaultMarketsTokenDetailsComponent( } override fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) { - model.openAddToPortfolioViaUserPortfolio(rawCurrencyId) + model.openAddToPortfolioViaUserPortfolio() } }, ) @@ -149,10 +149,9 @@ internal class DefaultMarketsTokenDetailsComponent( @Suppress("UNUSED_PARAMETER") config: AddToPortfolioSlotRoute, componentContext: ComponentContext, ): ComposableBottomSheetComponent { - val manager = model.addToPortfolioManagerOrNull() ?: return ComposableBottomSheetComponent.EMPTY return addToPortfolioComponentFactory.create( context = childByContext(componentContext), - params = AddToPortfolioComponent.Params(addToPortfolioManager = manager), + params = AddToPortfolioComponent.Params(addToPortfolioManager = model.addToPortfolioManager), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt index 2916584e41..2c3c85e529 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/market/details/MarketsTokenDetailsModel.kt @@ -40,7 +40,6 @@ import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.markets.* -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.usecase.GetNewsUseCase @@ -72,7 +71,6 @@ import com.tangem.utils.coroutines.saveIn import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -238,8 +236,13 @@ internal class MarketsTokenDetailsModel @Inject constructor( private val isAddToPortfolioAvailable: Boolean = params.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled - private var addToPortfolioManager: AddToPortfolioManager? = null - private var addToPortfolioListenersJob: Job? = null + val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings( + shouldSkipTokenActionsScreen = false, + ), + analyticsParams = AddToPortfolioManager.AnalyticsParams(params.analyticsParams?.source), + ) val state = MutableStateFlow( MarketsTokenDetailsUM( @@ -329,7 +332,7 @@ internal class MarketsTokenDetailsModel @Inject constructor( modelScope.launch { networksState.collect { tokenNetworkState -> - val manager = addToPortfolioManager ?: return@collect + val manager = addToPortfolioManager when (tokenNetworkState) { is TokenNetworksState.NetworksAvailable -> manager.setTokenNetworks(tokenNetworkState.networks) TokenNetworksState.NoNetworksAvailable -> manager.setTokenNetworks(emptyList()) @@ -337,6 +340,18 @@ internal class MarketsTokenDetailsModel @Inject constructor( } } } + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { addToPortfolioSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { addToPortfolioSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onAddedTokenClick.receiveAsFlow() + .onEach { result -> + addToPortfolioSheetNavigation.dismiss() + openTokenDetails(result) + } + .launchIn(modelScope) } fun openAddToPortfolio() { @@ -345,9 +360,9 @@ internal class MarketsTokenDetailsModel @Inject constructor( addToPortfolioSheetNavigation.activate(AddToPortfolioSlotRoute) } - fun openAddToPortfolioViaUserPortfolio(rawCurrencyId: CryptoCurrency.RawID) { + fun openAddToPortfolioViaUserPortfolio() { if (!isAddToPortfolioAvailable) return - prepareAddToPortfolioManager(AddToPortfolioManager.LaunchMode.ViaUserPortfolio(rawCurrencyId)) + prepareAddToPortfolioManager(AddToPortfolioManager.LaunchMode.ViaUserPortfolio) addToPortfolioSheetNavigation.activate(AddToPortfolioSlotRoute) } @@ -360,43 +375,15 @@ internal class MarketsTokenDetailsModel @Inject constructor( ) } - fun addToPortfolioManagerOrNull(): AddToPortfolioManager? = addToPortfolioManager - private fun prepareAddToPortfolioManager(launchMode: AddToPortfolioManager.LaunchMode) { - addToPortfolioListenersJob?.cancel() - val manager = addToPortfolioManagerFactory.create( - scope = modelScope, - settings = AddToPortfolioManager.Settings( - shouldSkipTokenActionsScreen = false, - launchMode = launchMode, - ), - analyticsParams = AddToPortfolioManager.AnalyticsParams(params.analyticsParams?.source), - ) + val manager = addToPortfolioManager manager.setTokenParams(params.token) + manager.updateLaunchMode(launchMode) when (val network = networksState.value) { is TokenNetworksState.NetworksAvailable -> manager.setTokenNetworks(network.networks) TokenNetworksState.NoNetworksAvailable -> manager.setTokenNetworks(emptyList()) else -> Unit } - addToPortfolioManager = manager - addToPortfolioListenersJob = modelScope.launch { - launch { - manager.onDismiss.receiveAsFlow().collect { - addToPortfolioSheetNavigation.dismiss() - } - } - launch { - manager.onSuccessAdded.receiveAsFlow().collect { - addToPortfolioSheetNavigation.dismiss() - } - } - launch { - manager.onAddedTokenClick.receiveAsFlow().collect { result -> - addToPortfolioSheetNavigation.dismiss() - openTokenDetails(result) - } - } - } } private fun initialLoad() { From df8f9abe3ba266d45d44a5f31e00950ed6df0f82 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 13:31:31 +0300 Subject: [PATCH 120/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 83bb5cdb69..f191596235 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -7,7 +7,7 @@ tangemBlockchainSdk = "develop-1498" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "develop-611" +tangemCardSdk = "develop-614" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From e3ff3e12948f25d87c0c32763a8109ee186bf0d5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Apr 2026 18:56:32 +0100 Subject: [PATCH 121/206] Updated on 2026-08-14 --- .../features/onboarding/v2/TitleProvider.kt | 6 +-- .../DefaultAddressSyncComponent.kt | 46 +++++++++++++---- .../addresssync/model/AddressSyncContract.kt | 1 + .../v2/addresssync/model/AddressSyncModel.kt | 45 ++++++++++------ .../addresssync/navigation/AddressSyncStep.kt | 3 +- .../ui/loading/AddressSyncLoading.kt | 39 ++++++++++++++ .../impl/DefaultOnboardingEntryComponent.kt | 21 ++++---- .../entry/impl/model/OnboardingEntryModel.kt | 13 +++-- .../DefaultOnboardingMultiWalletComponent.kt | 1 + .../backup/MultiWalletBackupComponent.kt | 5 +- .../Wallet1ChooseOptionComponent.kt | 5 +- .../MultiWalletCreateWalletComponent.kt | 5 +- .../finalize/MultiWalletFinalizeComponent.kt | 5 +- .../MultiWalletScanPrimaryComponent.kt | 5 +- .../MultiWalletSeedPhraseComponent.kt | 13 +++-- .../MultiWalletUpgradeWalletComponent.kt | 5 +- .../impl/model/OnboardingMultiWalletModel.kt | 11 +++- .../impl/DefaultOnboardingNoteComponent.kt | 7 ++- .../stepper/api/OnboardingStepperComponent.kt | 4 +- .../v2/stepper/impl/ui/OnboardingStepper.kt | 8 +-- .../onboarding/v2/title/OnboardingTitle.kt | 8 +++ .../impl/DefaultOnboardingTwinComponent.kt | 7 ++- .../impl/DefaultOnboardingVisaComponent.kt | 7 ++- .../addresssync/model/AddressSyncModelTest.kt | 51 +++++++++++-------- 24 files changed, 238 insertions(+), 83 deletions(-) create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt create mode 100644 features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/title/OnboardingTitle.kt diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/TitleProvider.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/TitleProvider.kt index 58dc49d42d..bb607e5554 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/TitleProvider.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/TitleProvider.kt @@ -1,9 +1,9 @@ package com.tangem.features.onboarding.v2 -import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.StateFlow interface TitleProvider { - val currentTitle: StateFlow - fun changeTitle(text: TextReference) + val currentTitle: StateFlow + fun changeTitle(title: OnboardingTitle) } \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index cd987407fe..952f61ca37 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -1,5 +1,6 @@ package com.tangem.features.onboarding.v2.addresssync +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -23,15 +24,18 @@ import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncState import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncButtonScreen import com.tangem.features.onboarding.v2.addresssync.ui.AddressSyncContent +import com.tangem.features.onboarding.v2.addresssync.ui.loading.AddressSyncLoading import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.pushnotifications.api.PushNotificationsComponent import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks import com.tangem.features.pushnotifications.api.PushNotificationsParams @OptIn(DelicateDecomposeApi::class) +@Suppress("LongParameterList") internal class DefaultAddressSyncComponent( appComponentContext: AppComponentContext, params: MultiWalletChildParams, + private val onBack: () -> Unit, private val addressSyncParams: AddressSyncComponent.Params, private val askBiometryComponentFactory: AskBiometryComponent.Factory, private val pushNotificationsComponentFactory: PushNotificationsComponent.Factory, @@ -44,7 +48,7 @@ internal class DefaultAddressSyncComponent( key = "innerStack", source = model.stackNavigation, serializer = null, - initialConfiguration = AddressSyncStep.ASK_BIOMETRY, + initialConfiguration = AddressSyncStep.LOADING, handleBackButton = true, childFactory = { configuration, factoryContext -> createChild( @@ -54,8 +58,29 @@ internal class DefaultAddressSyncComponent( }, ) + init { + model.initConfiguration() + } + @Composable override fun Content(modifier: Modifier) { + BackHandler(enabled = true) { + onBack() + } + + val state by model.state.collectAsStateWithLifecycle() + + LaunchedEffect(state) { + when (state) { + AddressSyncState.Exit -> handleExit() + is AddressSyncState.Success -> { + val shouldExit = (state as AddressSyncState.Success).shouldExit + if (shouldExit) handleExit() + } + AddressSyncState.Loading -> Unit + } + } + AddressSyncContent( modifier = modifier, childContent = { @@ -68,14 +93,23 @@ internal class DefaultAddressSyncComponent( ) } + private fun handleExit() { + if (addressSyncParams.isWalletStarted) { + router.popTo(AppRoute.Wallet) + } else { + router.replaceAll(AppRoute.Wallet) + } + } + private fun createChild(step: AddressSyncStep, childContext: AppComponentContext): ComposableContentComponent { return when (step) { + AddressSyncStep.LOADING -> ComposableContentComponent { AddressSyncLoading() } AddressSyncStep.ASK_BIOMETRY -> createAskBiometryComponent(childContext) AddressSyncStep.ASK_NOTIFICATIONS -> createPushNotificationComponent(childContext) AddressSyncStep.ADDRESS_SYNC -> ComposableContentComponent { val state by model.state.collectAsStateWithLifecycle() when (state) { - AddressSyncState.Loading -> Unit // todo shimmers will be implemented during [REDACTED_TASK_KEY] + AddressSyncState.Loading -> AddressSyncLoading() is AddressSyncState.Success -> AddressSyncButtonScreen( state = state as AddressSyncState.Success, modifier = Modifier.fillMaxSize(), @@ -83,13 +117,7 @@ internal class DefaultAddressSyncComponent( model.onIntent(AddressSyncIntent.Sync) }, ) - AddressSyncState.Exit -> LaunchedEffect(Unit) { - if (addressSyncParams.isWalletStarted) { - router.popTo(AppRoute.Wallet) - } else { - router.replaceAll(AppRoute.Wallet) - } - } + AddressSyncState.Exit -> AddressSyncLoading() } } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt index 734633db22..fb8b5ed050 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncContract.kt @@ -13,6 +13,7 @@ internal sealed class AddressSyncState { data class Success( val currencies: List, val isButtonLoading: Boolean = false, + val shouldExit: Boolean = false, ) : AddressSyncState() { val currenciesCount: Int = currencies.size } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt index 1471de14fb..59aba1e675 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModel.kt @@ -21,6 +21,7 @@ import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.MultiWalletInnerNavigationState import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger @@ -59,9 +60,6 @@ internal class AddressSyncModel @Inject constructor( stackSize = AddressSyncStep.ASK_BIOMETRY.pageNumber, stackMaxSize = ADDRESS_SYNC_MAX_STEPS, ) - modelScope.launch { - trySkippingScreen(AddressSyncStep.ASK_BIOMETRY) - } fetchWalletCrypto() } @@ -76,24 +74,31 @@ internal class AddressSyncModel @Inject constructor( stackNavigation.replaceCurrent(configuration = next.step) updateStepperPage(next) updateTitle(next) - modelScope.launch { trySkippingScreen(next.step) } + modelScope.launch { tryToSkipNotificationScreen(next.step) } } - private suspend fun trySkippingScreen(step: AddressSyncStep) { - when (step) { - AddressSyncStep.ASK_BIOMETRY -> { - val shouldShowAskBiometry = canUseBiometryUseCase.strict() && shouldShowAskBiometryUseCase() - if (shouldShowAskBiometry.not()) { - nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ASK_NOTIFICATIONS)) - } + fun initConfiguration() { + modelScope.launch { + val shouldShowAskBiometry = canUseBiometryUseCase.strict() && shouldShowAskBiometryUseCase() + val shouldShowAskNotification = shouldAskPermissionUseCase(PUSH_PERMISSION) + val step = when { + !shouldShowAskBiometry && !shouldShowAskNotification -> AddressSyncStep.ADDRESS_SYNC + !shouldShowAskBiometry -> AddressSyncStep.ASK_NOTIFICATIONS + else -> AddressSyncStep.ASK_BIOMETRY } + nextScreen(AddressSyncIntent.Next(step)) + } + } + + private suspend fun tryToSkipNotificationScreen(step: AddressSyncStep) { + when (step) { AddressSyncStep.ASK_NOTIFICATIONS -> { val shouldShowAskNotification = shouldAskPermissionUseCase(PUSH_PERMISSION) if (shouldShowAskNotification.not()) { nextScreen(AddressSyncIntent.Next(step = AddressSyncStep.ADDRESS_SYNC)) } } - AddressSyncStep.ADDRESS_SYNC -> Unit + AddressSyncStep.LOADING, AddressSyncStep.ASK_BIOMETRY, AddressSyncStep.ADDRESS_SYNC -> Unit } } @@ -106,9 +111,14 @@ internal class AddressSyncModel @Inject constructor( } private fun updateTitle(next: AddressSyncIntent.Next) { - params.parentParams.titleProvider.changeTitle( - text = resourceReference(next.step.stringId), - ) + next.step.stringId?.let { stringId -> + params.parentParams.titleProvider.changeTitle( + title = OnboardingTitle( + text = resourceReference(stringId), + shouldForceTitle = true, + ), + ) + } } private fun fetchWalletCrypto() { @@ -164,7 +174,10 @@ internal class AddressSyncModel @Inject constructor( launch { fetchNetworks(cryptoCurrencies) }, launch { fetchStaking(cryptoCurrencies) }, ).joinAll() - state.value = AddressSyncState.Exit + state.update { addressSyncState -> + addressSyncState as AddressSyncState.Success + addressSyncState.copy(shouldExit = true) + } }, ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt index f9d48107ca..a0fc352db1 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/navigation/AddressSyncStep.kt @@ -3,7 +3,8 @@ package com.tangem.features.onboarding.v2.addresssync.navigation import androidx.annotation.StringRes import com.tangem.features.onboarding.v2.impl.R -internal enum class AddressSyncStep(val pageNumber: Int, @StringRes val stringId: Int) { +internal enum class AddressSyncStep(val pageNumber: Int, @StringRes val stringId: Int?) { + LOADING(pageNumber = 0, stringId = null), ASK_BIOMETRY(pageNumber = 1, stringId = R.string.onboarding_navbar_title_biometrics), ASK_NOTIFICATIONS(pageNumber = 2, stringId = R.string.onboarding_title_notifications), ADDRESS_SYNC(pageNumber = 3, stringId = R.string.onboarding_navbar_title_last_step), diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt new file mode 100644 index 0000000000..c83a540703 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt @@ -0,0 +1,39 @@ +package com.tangem.features.onboarding.v2.addresssync.ui.loading + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview + +@Composable +fun AddressSyncLoading(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier + .size(TangemTheme.dimens.size30) + .padding(4.dp), + color = TangemTheme.colors.icon.accent, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun AddressSyncLoadingPreview() { + TangemThemePreview { + AddressSyncLoading() + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt index ae53ea07e1..800b3b91e0 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/DefaultOnboardingEntryComponent.kt @@ -5,7 +5,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.stack.* +import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.value.ObserveLifecycleMode import com.arkivanov.decompose.value.Value import com.arkivanov.decompose.value.subscribe @@ -22,6 +23,7 @@ import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute import com.tangem.features.onboarding.v2.entry.impl.ui.OnboardingEntry import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.stepper.api.OnboardingStepperComponent +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn import dagger.assisted.Assisted @@ -111,8 +113,14 @@ internal class DefaultOnboardingEntryComponent @AssistedInject constructor( } }.saveIn(innerNavigationLinkJobHolder) } else { - stepperComponent.state.update { - it.copy( + val titleText = when (stack.active.configuration) { + is OnboardingRoute.ManageTokens -> resourceReference(R.string.main_manage_tokens) + is OnboardingRoute.AskBiometry -> resourceReference(R.string.onboarding_navbar_save_wallet) + is OnboardingRoute.Done -> resourceReference(R.string.onboarding_done_header) + else -> error("Unsupported route") + } + stepperComponent.state.update { stepperState -> + stepperState.copy( currentStep = when (stack.active.configuration) { is OnboardingRoute.ManageTokens -> 7 is OnboardingRoute.AskBiometry -> 8 @@ -120,12 +128,7 @@ internal class DefaultOnboardingEntryComponent @AssistedInject constructor( else -> error("Unsupported route") }, steps = 9, - title = when (stack.active.configuration) { - is OnboardingRoute.ManageTokens -> resourceReference(R.string.main_manage_tokens) - is OnboardingRoute.AskBiometry -> resourceReference(R.string.onboarding_navbar_save_wallet) - is OnboardingRoute.Done -> resourceReference(R.string.onboarding_done_header) - else -> error("Unsupported route") - }, + title = OnboardingTitle(titleText), showProgress = true, ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt index baefd2245f..70b1581b6f 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModel.kt @@ -9,7 +9,6 @@ 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.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -26,6 +25,7 @@ import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent.Mode import com.tangem.features.onboarding.v2.entry.impl.analytics.OnboardingEntryEvent import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent import com.tangem.sdk.api.TangemSdkManager @@ -51,9 +51,14 @@ internal class OnboardingEntryModel @Inject constructor( val stackNavigation = StackNavigation() val titleProvider = object : TitleProvider { - override val currentTitle = MutableStateFlow(stringReference("")) - override fun changeTitle(text: TextReference) { - currentTitle.value = text + override val currentTitle = MutableStateFlow( + OnboardingTitle( + text = stringReference(""), + ), + ) + + override fun changeTitle(title: OnboardingTitle) { + currentTitle.value = title } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt index 3d96a24e74..c1c8142bb6 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/DefaultOnboardingMultiWalletComponent.kt @@ -201,6 +201,7 @@ internal class DefaultOnboardingMultiWalletComponent @AssistedInject constructor addressSyncParams = AddressSyncComponent.Params( isWalletStarted = mode.isWalletStarted, ), + onBack = { model.onBack() }, askBiometryComponentFactory = askBiometryComponentFactory, pushNotificationsComponentFactory = pushNotificationsComponentFactory, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/MultiWalletBackupComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/MultiWalletBackupComponent.kt index 26eb6e5be7..3a8e6451bf 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/MultiWalletBackupComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/backup/MultiWalletBackupComponent.kt @@ -12,6 +12,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.model.MultiWalletBackupModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.backup.ui.MultiWalletBackup +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -33,7 +34,9 @@ class MultiWalletBackupComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.onboarding_navbar_title_creating_backup), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_navbar_title_creating_backup), + ), ) componentScope.launch { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/Wallet1ChooseOptionComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/Wallet1ChooseOptionComponent.kt index 391228b718..8db18118a9 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/Wallet1ChooseOptionComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/chooseoption/Wallet1ChooseOptionComponent.kt @@ -12,6 +12,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.chooseoption.model.Wallet1ChooseOptionModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.chooseoption.ui.Wallet1ChooseOption import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -32,7 +33,9 @@ class Wallet1ChooseOptionComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.onboarding_getting_started), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_getting_started), + ), ) componentScope.launch { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/MultiWalletCreateWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/MultiWalletCreateWalletComponent.kt index 2cf4d4435f..c2644e4394 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/MultiWalletCreateWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/createwallet/MultiWalletCreateWalletComponent.kt @@ -14,6 +14,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.model.MultiWalletCreateWalletModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.createwallet.ui.MultiWalletCreateWallet import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -35,7 +36,9 @@ internal class MultiWalletCreateWalletComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.onboarding_create_wallet_header), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_create_wallet_header), + ), ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt index 5bb2626fe5..d93f71cc47 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/MultiWalletFinalizeComponent.kt @@ -14,6 +14,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model.MultiWalletFinalizeModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.MultiWalletFinalize +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.onboarding.v2.util.ResetCardsComponent import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.launchIn @@ -49,7 +50,9 @@ internal class MultiWalletFinalizeComponent( ) } params.parentParams.titleProvider.changeTitle( - resourceReference(R.string.onboarding_button_finalize_backup), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_button_finalize_backup), + ), ) componentScope.launch { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/scanprimary/MultiWalletScanPrimaryComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/scanprimary/MultiWalletScanPrimaryComponent.kt index 8e84c772ff..e302b74e1a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/scanprimary/MultiWalletScanPrimaryComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/scanprimary/MultiWalletScanPrimaryComponent.kt @@ -10,6 +10,7 @@ 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.scanprimary.model.MultiWalletScanPrimaryModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.scanprimary.ui.MultiWalletScanPrimary +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -30,7 +31,9 @@ internal class MultiWalletScanPrimaryComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.onboarding_navbar_title_creating_backup), + title = OnboardingTitle( + text = resourceReference(R.string.onboarding_navbar_title_creating_backup), + ), ) componentScope.launch { model.onDone.collect { onDone() } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/MultiWalletSeedPhraseComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/MultiWalletSeedPhraseComponent.kt index 2f85b1a45c..c58d8d27e6 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/MultiWalletSeedPhraseComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/seedphrase/MultiWalletSeedPhraseComponent.kt @@ -16,6 +16,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.model import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.MultiWalletSeedPhrase import com.tangem.features.onboarding.v2.multiwallet.impl.child.seedphrase.ui.state.MultiWalletSeedPhraseUM import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -33,17 +34,17 @@ internal class MultiWalletSeedPhraseComponent( init { componentScope.launch { - model.uiState.collect { + model.uiState.collect { state -> // change stepper state based on the stack of the current step @Suppress("MagicNumber") params.innerNavigation.update { st -> st.copy( - stackSize = 3 + it.order, + stackSize = 3 + state.order, stackMaxSize = 11, ) } - val title = when (it) { + val title = when (state) { is MultiWalletSeedPhraseUM.Import -> R.string.onboarding_seed_intro_button_import is MultiWalletSeedPhraseUM.GenerateSeedPhrase, is MultiWalletSeedPhraseUM.GeneratedWordsCheck, @@ -51,7 +52,11 @@ internal class MultiWalletSeedPhraseComponent( -> R.string.onboarding_create_wallet_header } - params.parentParams.titleProvider.changeTitle(text = resourceReference(title)) + params.parentParams.titleProvider.changeTitle( + title = OnboardingTitle( + text = resourceReference(title), + ), + ) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt index 222b173ffb..3165bff4d4 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/upgradewallet/MultiWalletUpgradeWalletComponent.kt @@ -14,6 +14,7 @@ import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChild import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.model.MultiWalletUpgradeWalletModel import com.tangem.features.onboarding.v2.multiwallet.impl.child.upgradewallet.ui.MultiWalletUpgradeWallet import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -35,7 +36,9 @@ internal class MultiWalletUpgradeWalletComponent( } params.parentParams.titleProvider.changeTitle( - text = resourceReference(R.string.common_tangem), + OnboardingTitle( + text = resourceReference(R.string.common_tangem), + ), ) } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt index 0d3c27e10a..fe78a9bb43 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModel.kt @@ -18,6 +18,7 @@ import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletCo import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState.FinalizeStage import com.tangem.features.onboarding.v2.multiwallet.impl.ui.state.OnboardingMultiWalletUM +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.operations.attestation.ArtworkSize import com.tangem.operations.backup.BackupService import com.tangem.sdk.api.BackupServiceHolder @@ -72,7 +73,11 @@ internal class OnboardingMultiWalletModel @Inject constructor( modelScope.launch { onboardingRepository.clearUnfinishedFinalizeOnboarding() if (params.mode is OnboardingMultiWalletComponent.Mode.AddressSync) { - router.replaceAll(AppRoute.Wallet) + if (params.mode.isWalletStarted) { + router.popTo(AppRoute.Wallet) + } else { + router.replaceAll(AppRoute.Wallet) + } } else { router.pop() } @@ -177,7 +182,9 @@ internal class OnboardingMultiWalletModel @Inject constructor( private fun initScreenTitle() { val title = screenTitleByStep(getInitialStep()) - params.titleProvider.changeTitle(title) + params.titleProvider.changeTitle( + title = OnboardingTitle(text = title), + ) } private fun loadCardArtwork() { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt index 313506dfb3..b1ca27df6d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/note/impl/DefaultOnboardingNoteComponent.kt @@ -27,6 +27,7 @@ import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteCommonSta import com.tangem.features.onboarding.v2.note.impl.model.OnboardingNoteModel import com.tangem.features.onboarding.v2.note.impl.route.ONBOARDING_NOTE_STEPS_COUNT import com.tangem.features.onboarding.v2.note.impl.route.OnboardingNoteRoute +import com.tangem.features.onboarding.v2.title.OnboardingTitle import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -82,7 +83,11 @@ internal class DefaultOnboardingNoteComponent @AssistedInject constructor( // sets title and stepper value childStack.subscribe(lifecycle) { stack -> val currentRoute = stack.active.configuration - params.titleProvider.changeTitle(TextReference.Res(R.string.onboarding_title)) + params.titleProvider.changeTitle( + title = OnboardingTitle( + TextReference.Res(R.string.onboarding_title), + ), + ) model.updateStepForNewRoute(currentRoute) } } diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/api/OnboardingStepperComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/api/OnboardingStepperComponent.kt index 8943397d3d..647af1b9ce 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/api/OnboardingStepperComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/api/OnboardingStepperComponent.kt @@ -3,8 +3,8 @@ package com.tangem.features.onboarding.v2.stepper.api import androidx.annotation.IntRange import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.scan.ScanResponse +import com.tangem.features.onboarding.v2.title.OnboardingTitle import kotlinx.coroutines.flow.MutableStateFlow internal interface OnboardingStepperComponent : ComposableContentComponent { @@ -12,7 +12,7 @@ internal interface OnboardingStepperComponent : ComposableContentComponent { data class StepperState( @IntRange(from = 0) val currentStep: Int, @IntRange(from = 0) val steps: Int, - val title: TextReference, + val title: OnboardingTitle, val showProgress: Boolean, ) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/ui/OnboardingStepper.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/ui/OnboardingStepper.kt index e3e01fe44d..340fbe3fc2 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/ui/OnboardingStepper.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/stepper/impl/ui/OnboardingStepper.kt @@ -23,6 +23,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.onboarding.v2.impl.R import com.tangem.features.onboarding.v2.stepper.api.OnboardingStepperComponent +import com.tangem.features.onboarding.v2.title.OnboardingTitle @Composable internal fun OnboardingStepper( @@ -51,13 +52,12 @@ internal fun OnboardingStepper( startButton = TopAppBarButtonUM.Back(onBackClick), endButton = TopAppBarButtonUM.Icon(iconRes = R.drawable.ic_chat_24, onClicked = onSupportButtonClick) .takeIf { state.steps != state.currentStep }, - title = if (state.steps == state.currentStep) { + title = if (state.steps == state.currentStep && !state.title.shouldForceTitle) { resourceReference(R.string.common_done) } else { - state.title + state.title.text }, containerColor = TangemTheme.colors.background.primary, - modifier = modifier, ) TangemLinearProgressIndicator( @@ -89,7 +89,7 @@ private fun OnboardingStepper_Preview() { state = OnboardingStepperComponent.StepperState( currentStep = 2, steps = 3, - title = resourceReference(R.string.common_done), + title = OnboardingTitle(text = resourceReference(R.string.common_done)), showProgress = true, ), onBackClick = {}, diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/title/OnboardingTitle.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/title/OnboardingTitle.kt new file mode 100644 index 0000000000..c71a088c59 --- /dev/null +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/title/OnboardingTitle.kt @@ -0,0 +1,8 @@ +package com.tangem.features.onboarding.v2.title + +import com.tangem.core.ui.extensions.TextReference + +data class OnboardingTitle( + val text: TextReference, + val shouldForceTitle: Boolean = false, +) \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt index 9879c7252b..838912346d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/twin/impl/DefaultOnboardingTwinComponent.kt @@ -12,6 +12,7 @@ import com.tangem.core.decompose.navigation.inner.InnerNavigationHolder import com.tangem.core.decompose.navigation.inner.InnerNavigationState import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.onboarding.v2.impl.R +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent import com.tangem.features.onboarding.v2.twin.impl.model.OnboardingTwinModel import com.tangem.features.onboarding.v2.twin.impl.ui.OnboardingTwin @@ -28,7 +29,11 @@ internal class DefaultOnboardingTwinComponent @AssistedInject constructor( private val model: OnboardingTwinModel = getOrCreateModel(params) init { - params.titleProvider.changeTitle(resourceReference(R.string.twins_recreate_toolbar)) + params.titleProvider.changeTitle( + title = OnboardingTitle( + text = resourceReference(R.string.twins_recreate_toolbar), + ), + ) } override val innerNavigation: InnerNavigation = object : InnerNavigation { diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt index 047c16e1d5..3c6adb964d 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/visa/impl/DefaultOnboardingVisaComponent.kt @@ -17,6 +17,7 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerNavigation import com.tangem.core.decompose.navigation.inner.InnerNavigationState import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.onboarding.v2.visa.api.OnboardingVisaComponent import com.tangem.features.onboarding.v2.visa.impl.child.accesscode.OnboardingVisaAccessCodeComponent import com.tangem.features.onboarding.v2.visa.impl.child.approve.OnboardingVisaApproveComponent @@ -87,7 +88,11 @@ internal class DefaultOnboardingVisaComponent @AssistedInject constructor( // sets title and stepper value childStack.subscribe(lifecycle) { stack -> val currentRoute = stack.active.configuration - params.titleProvider.changeTitle(currentRoute.screenTitle()) + params.titleProvider.changeTitle( + title = OnboardingTitle( + text = currentRoute.screenTitle(), + ), + ) model.updateStepForNewRoute(currentRoute) } } diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt index c3d4a2df19..9b9491aded 100644 --- a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/addresssync/model/AddressSyncModelTest.kt @@ -21,6 +21,7 @@ import com.tangem.features.onboarding.v2.addresssync.navigation.AddressSyncStep import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent import com.tangem.features.onboarding.v2.multiwallet.impl.MultiWalletInnerNavigationState import com.tangem.features.onboarding.v2.multiwallet.impl.child.MultiWalletChildParams +import com.tangem.features.onboarding.v2.title.OnboardingTitle import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* @@ -86,18 +87,19 @@ internal class AddressSyncModelTest { val state = testInnerNavigation.value Assertions.assertEquals(AddressSyncStep.ASK_BIOMETRY.pageNumber, state.stackSize) - Assertions.assertEquals(AddressSyncStep.entries.size, state.stackMaxSize) + Assertions.assertEquals(3, state.stackMaxSize) } @Test - fun `GIVEN biometry allowed AND should show biometry WHEN Next ASK_BIOMETRY THEN ASK_BIOMETRY on top`() = runTest { + fun `GIVEN biometry allowed AND should show biometry WHEN initConfiguration THEN ASK_BIOMETRY on top`() = runTest { coEvery { canUseBiometryUseCase.strict() } returns true coEvery { shouldShowAskBiometryUseCase() } returns true + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY)) + model.initConfiguration() advanceUntilIdle() Assertions.assertEquals(listOf(AddressSyncStep.ASK_BIOMETRY), stack) @@ -105,16 +107,16 @@ internal class AddressSyncModelTest { } @Test - fun `GIVEN biometry skipped AND notifications required WHEN Next ASK_BIOMETRY THEN ASK_NOTIFICATIONS on top`() = + fun `GIVEN biometry not needed AND notifications required WHEN initConfiguration THEN ASK_NOTIFICATIONS on top`() = runTest { - coEvery { canUseBiometryUseCase.strict() } returns true + coEvery { canUseBiometryUseCase.strict() } returns false coEvery { shouldShowAskBiometryUseCase() } returns false coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns true val model = createModel(this) val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY)) + model.initConfiguration() advanceUntilIdle() Assertions.assertEquals(listOf(AddressSyncStep.ASK_NOTIFICATIONS), stack) @@ -122,20 +124,21 @@ internal class AddressSyncModelTest { } @Test - fun `GIVEN biometry skipped AND notifications skipped WHEN Next ASK_BIOMETRY THEN ADDRESS_SYNC on top`() = runTest { - coEvery { canUseBiometryUseCase.strict() } returns true - coEvery { shouldShowAskBiometryUseCase() } returns false - coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false + fun `GIVEN biometry not needed AND notifications skipped WHEN initConfiguration THEN ADDRESS_SYNC on top`() = + runTest { + coEvery { canUseBiometryUseCase.strict() } returns false + coEvery { shouldShowAskBiometryUseCase() } returns false + coEvery { shouldAskPermissionUseCase(PUSH_PERMISSION) } returns false - val model = createModel(this) - val stack = model.stackNavigation.trackStack() + val model = createModel(this) + val stack = model.stackNavigation.trackStack() - model.onIntent(AddressSyncIntent.Next(step = AddressSyncStep.ASK_BIOMETRY)) - advanceUntilIdle() + model.initConfiguration() + advanceUntilIdle() - Assertions.assertEquals(listOf(AddressSyncStep.ADDRESS_SYNC), stack) - assertStepperAndTitleFor(AddressSyncStep.ADDRESS_SYNC) - } + Assertions.assertEquals(listOf(AddressSyncStep.ADDRESS_SYNC), stack) + assertStepperAndTitleFor(AddressSyncStep.ADDRESS_SYNC) + } @Test fun `GIVEN notifications required WHEN Next ASK_NOTIFICATIONS THEN ASK_NOTIFICATIONS on top`() = runTest { @@ -244,12 +247,13 @@ internal class AddressSyncModelTest { } @Test - fun `GIVEN success state WHEN Sync THEN state becomes Exit`() = runTest { + fun `GIVEN success state WHEN Sync THEN state becomes Success with shouldExit`() = runTest { val currencies = listOf( mockk { every { network } returns mockk() }, mockk { every { network } returns mockk() }, mockk { every { network } returns mockk() }, ) + val expected = AddressSyncState.Success(currencies, isButtonLoading = true, shouldExit = true) every { multiAccountListSupplier() } returns flowOf( listOf( AccountList.empty( @@ -274,7 +278,7 @@ internal class AddressSyncModelTest { coVerify { derivePublicKeysUseCase(walletId, currencies) } coVerify { multiNetworkStatusFetcher.invoke(any()) } coVerify { multiStakingBalanceFetcher(any()) } - Assertions.assertEquals(AddressSyncState.Exit, model.state.value) + Assertions.assertEquals(expected, model.state.value) } @Test @@ -307,7 +311,14 @@ internal class AddressSyncModelTest { private fun assertStepperAndTitleFor(step: AddressSyncStep) { Assertions.assertEquals(step.pageNumber, testInnerNavigation.value.stackSize) - verify { titleProvider.changeTitle(resourceReference(step.stringId)) } + verify { + titleProvider.changeTitle( + title = OnboardingTitle( + text = resourceReference(step.stringId!!), + shouldForceTitle = true, + ), + ) + } } private fun StackNavigation.trackStack(): List { From 5406979dfb86d633929e29afedc4178a17d58d01 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 10:06:58 +0100 Subject: [PATCH 122/206] Updated on 2026-08-14 --- .../onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt index c83a540703..f7c24fdd8a 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/ui/loading/AddressSyncLoading.kt @@ -15,7 +15,7 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @Composable -fun AddressSyncLoading(modifier: Modifier = Modifier) { +internal fun AddressSyncLoading(modifier: Modifier = Modifier) { Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, From 6587ba5e1ee37017cafa0767adfb99d312b126d4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 14:23:37 +0300 Subject: [PATCH 123/206] Updated on 2026-08-14 --- .../account/status/usecase/IsAccountsModeEnabledUseCase.kt | 1 - .../features/approval/impl/model/GiveApprovalModelTest.kt | 2 -- .../choosetoken/impl/converter/ChooseTokenListItemConverter.kt | 1 + .../presentation/wallet/domain/GetMultiWalletWarningsFactory.kt | 2 -- 4 files changed, 1 insertion(+), 5 deletions(-) diff --git a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt index ae529e245c..6667cb8e2a 100644 --- a/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt +++ b/domain/account/status/src/main/java/com/tangem/domain/account/status/usecase/IsAccountsModeEnabledUseCase.kt @@ -54,7 +54,6 @@ class IsAccountsModeEnabledUseCase( is PaymentAccountStatusValue.IssuingCard, is PaymentAccountStatusValue.Loaded, is PaymentAccountStatusValue.Loading, - is PaymentAccountStatusValue.Locked, is PaymentAccountStatusValue.UnderReview, -> true } diff --git a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt index fc097acb8c..cf09525c4c 100644 --- a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt +++ b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt @@ -41,7 +41,6 @@ class GiveApprovalModelTest { private val getFeeForGaslessUseCase: GetFeeForGaslessUseCase = mockk(relaxed = true) private val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true) private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true) - private val uiMessageSender: UiMessageSender = mockk(relaxed = true) private val urlOpener: UrlOpener = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true) @@ -81,7 +80,6 @@ class GiveApprovalModelTest { getFeeForGaslessUseCase = getFeeForGaslessUseCase, getFeeForTokenUseCase = getFeeForTokenUseCase, createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, - uiMessageSender = uiMessageSender, urlOpener = urlOpener, getUserWalletUseCase = getUserWalletUseCase, analyticsEventHandler = analyticsEventHandler, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt index 3ad65aa9dd..277772d1df 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt @@ -186,6 +186,7 @@ internal class ChooseTokenListItemConverter( PaymentAccountStatusValue.NotCreated, is PaymentAccountStatusValue.UnderReview, PaymentAccountStatusValue.Loading, + PaymentAccountStatusValue.Empty, -> return null is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 5f27a893f7..193dfee657 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -27,8 +27,6 @@ import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.promo.ShouldShowPromoWalletUseCase import com.tangem.domain.promo.models.PromoId import com.tangem.domain.settings.IsReadyToShowRateAppUseCase -import com.tangem.domain.tokensync.model.TokenSyncProgress -import com.tangem.domain.tokensync.usecase.ObserveTokenSyncUseCase import com.tangem.domain.assetsdiscovery.model.AssetsDiscoveryProgress import com.tangem.domain.assetsdiscovery.usecase.ObserveAssetsDiscoveryUseCase import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase From 596cbb40cb63d2687ded2d3a95ba6cf77d48c303 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 17:32:04 +0500 Subject: [PATCH 124/206] Updated on 2026-08-14 --- .../res/drawable/img_visa_card_48x32.webp | Bin 0 -> 772 bytes .../components/TangemPayCardPageComponent.kt | 14 - ...faultTangemPayDetailsContainerComponent.kt | 44 +-- ...onent.kt => TangemPayCardPageComponent.kt} | 51 ++-- .../TangemPayCardPageScreenComponent.kt | 15 +- .../TangemPayChangePinSuccessComponent.kt | 4 +- .../components/TangemPayDetailsComponent.kt | 20 -- .../di/TangemPayDetailsFeatureModule.kt | 8 - .../entity/TangemPayCardNavigation.kt | 30 ++ .../entity/TangemPayDetailsNavigation.kt | 9 - .../entity/TangemPayDetailsStateFactory.kt | 74 ++--- .../entity/TangemPayDetailsTopBarConfig.kt | 16 +- .../tangempay/entity/TangemPayDetailsUM.kt | 15 +- .../setup/TangemPayCardLimitSetupModel.kt | 4 +- ...TangemPayCardLimitSetupSuccessComponent.kt | 4 +- .../model/TangemPayCardDetailsBlockModel.kt | 8 +- .../tangempay/model/TangemPayCardPageModel.kt | 20 +- .../model/TangemPayChangePinModel.kt | 12 +- .../tangempay/model/TangemPayDetailsModel.kt | 194 ++----------- .../DetailsAddToWalletBannerTransformer.kt | 22 -- .../transformers/DetailsBalanceTransformer.kt | 11 +- .../TangemPayCardFrozenStateConverter.kt | 18 -- ...TangemPayFreezeUnfreezeStateTransformer.kt | 57 +--- .../TangemPayAccountDetailsInnerRoute.kt | 13 + .../TangemPayCardDetailsInnerRoute.kt | 29 ++ .../navigation/TangemPayDetailsInnerRoute.kt | 28 -- .../tangempay/ui/TangemPayDetailsScreen.kt | 258 ++++++++++-------- .../tangempay/utils/TangemPayDetailIntents.kt | 5 +- .../utils/TangemPayMessagesFactory.kt | 16 ++ 29 files changed, 364 insertions(+), 635 deletions(-) create mode 100644 core/ui/src/main/res/drawable/img_visa_card_48x32.webp delete mode 100644 features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt rename features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/{DefaultTangemPayCardPageComponent.kt => TangemPayCardPageComponent.kt} (65%) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt delete mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt delete mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardFrozenStateConverter.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt delete mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt diff --git a/core/ui/src/main/res/drawable/img_visa_card_48x32.webp b/core/ui/src/main/res/drawable/img_visa_card_48x32.webp new file mode 100644 index 0000000000000000000000000000000000000000..8fa2d328ee81dda7bb0e5e2a2a4a132b67911ed3 GIT binary patch literal 772 zcmV+f1N;0^Nk&He0ssJ4MM6+kP&gp)0ssKe5dfV5D!>4M06vjOp-d&DBO)RdSXi(U z32AQKUD~u}Q83MH!LTj~%Fa#9^4tBiGes8(%#3qE2M?kss{HQI?&b3cDerljT>k)E zgAATECY;m|DFQ${N-W2WbD8@`+^n=g0>~j4k@;9W1m{|n5R>TH^TM-E^|$+8;y7bG zA>d24E`t|tg@rAcrsAsq{FN@-fPeu0@eEZ#{0S=@g};Y)Hoc6^jW6P}cTdv(@?Qzi zMD=0jSb|;N4tOKe z_h4V8dTOkOK3K2{xs|=>C9Qf~tkmier#~6-X8O^kQ{pqxQ2~Yu0Mg7aPvflu>CRLctnA=^jfuzDQ!rR%% znZF7)h?bAa8hwx{1l1Dy{pmkz((yOm^14M|x5noz0qy7FGIIZZS09$^2&Xa+;oa$P zTEN?9=B7Bw(K)sgp~qQ7(x+&v9cAR%bwUuj7c`#`vhvc;L}4EWN`6ocmKXL+MT zHR#H=&=!^rD1v&I>)i4)I@)&eIzCWa^zxaIm%U$$|6i<=AT(`^w2XV^oRXPQwz9GR znTa~h_3V!FBTHfaJ71H69^s-JDkMn)qCq~_u_3p<`&DnBdh_l6q1RnnZLK)yUq*`h@O@)%aXm5j_!`s%{eL3k3uZ2<-f*pmRM!rXd%x z{*s?`Y{iBm;;}1=f3iPr+@7JePmeZ!LsRgD;gKIAYj%~Yt+!=`+c7|1s1)+N@6N&L z0|8c&3ncD+M}PY3g%;|t6EptAu-=DkKmmt_43Ku&0R2F7n%e*=c0x6}L&Z39Z~y=f C<%PWf literal 0 HcmV?d00001 diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt deleted file mode 100644 index 9ec8ddecd1..0000000000 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.features.tangempay.components - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayDetailsConfig - -interface TangemPayCardPageComponent : ComposableContentComponent { - data class Params( - val userWalletId: UserWalletId, - val config: TangemPayDetailsConfig, - ) - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 272c8d3b39..52b0069572 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -16,9 +16,7 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.tangempay.components.express.ExpressTransactionsComponentProvider -import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupComponent -import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupSuccessComponent -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -27,13 +25,14 @@ import dagger.assisted.AssistedInject internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, @Assisted private val params: TangemPayDetailsContainerComponent.Params, + private val tangemPayCardPageFactory: TangemPayCardPageComponent.Factory, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, private val expressTransactionsComponentProvider: ExpressTransactionsComponentProvider, ) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent { - private val stackNavigation = StackNavigation() + private val stackNavigation = StackNavigation() - private val innerRouter = InnerRouter( + private val innerRouter = InnerRouter( stackNavigation = stackNavigation, popCallback = { onChildBack() }, ) @@ -41,8 +40,8 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru private val childStack = childStack( key = "tangemPayDetailsInnerStack", source = stackNavigation, - serializer = TangemPayDetailsInnerRoute.serializer(), - initialConfiguration = TangemPayDetailsInnerRoute.Details, + serializer = TangemPayAccountDetailsInnerRoute.serializer(), + initialConfiguration = TangemPayAccountDetailsInnerRoute.AccountDetails, childFactory = ::screenChild, ) @@ -57,39 +56,18 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru } private fun screenChild( - config: TangemPayDetailsInnerRoute, + config: TangemPayAccountDetailsInnerRoute, componentContext: ComponentContext, ): ComposableContentComponent = when (config) { - TangemPayDetailsInnerRoute.Details -> TangemPayDetailsComponent( + TangemPayAccountDetailsInnerRoute.AccountDetails -> TangemPayDetailsComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = params, tokenReceiveComponentFactory = tokenReceiveComponentFactory, expressTransactionsComponentProvider = expressTransactionsComponentProvider, ) - TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( - appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = params, - ) - TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( - appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - ) - TangemPayDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( - appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = params, - ) - TangemPayDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( - appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = TangemPayDetailsContainerComponent.Params( - userWalletId = params.userWalletId, - config = params.config, - ), - ) - TangemPayDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent( - appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), - params = params, - ) - TangemPayDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent( - appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create( + context = childByContext(componentContext = componentContext, router = innerRouter), + params = TangemPayCardPageComponent.Params(userWalletId = params.userWalletId, config = params.config), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt similarity index 65% rename from features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt rename to features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt index 7271f7fd3a..6d2e0dc50b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayCardPageComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageComponent.kt @@ -1,5 +1,7 @@ package com.tangem.features.tangempay.components +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -11,25 +13,28 @@ import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext +import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupComponent import com.tangem.features.tangempay.limit.setup.TangemPayCardLimitSetupSuccessComponent -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.features.tokenreceive.TokenReceiveComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( +internal class TangemPayCardPageComponent @AssistedInject constructor( @Assisted private val appComponentContext: AppComponentContext, - @Assisted private val params: TangemPayCardPageComponent.Params, + @Assisted private val params: Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, -) : AppComponentContext by appComponentContext, TangemPayCardPageComponent { +) : ComposableContentComponent, AppComponentContext by appComponentContext { - private val stackNavigation = StackNavigation() + private val stackNavigation = StackNavigation() - private val innerRouter = InnerRouter( + private val innerRouter = InnerRouter( stackNavigation = stackNavigation, popCallback = { onChildBack() }, ) @@ -37,63 +42,64 @@ internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( private val childStack = childStack( key = "tangemPayCardPageInnerStack", source = stackNavigation, - serializer = TangemPayDetailsInnerRoute.serializer(), - initialConfiguration = TangemPayDetailsInnerRoute.Details, + serializer = TangemPayCardDetailsInnerRoute.serializer(), + initialConfiguration = TangemPayCardDetailsInnerRoute.Details, childFactory = ::screenChild, ) - @Suppress("ReusedModifierInstance") @Composable override fun Content(modifier: Modifier) { val childStack by childStack.subscribeAsState() + BackHandler(onBack = ::onChildBack) Children( + modifier = modifier, stack = childStack, ) { child -> - child.instance.Content(modifier = modifier) + child.instance.Content(modifier = Modifier.fillMaxSize()) } } private fun screenChild( - config: TangemPayDetailsInnerRoute, + config: TangemPayCardDetailsInnerRoute, componentContext: ComponentContext, ): ComposableContentComponent = when (config) { - TangemPayDetailsInnerRoute.Details -> TangemPayCardPageScreenComponent( + TangemPayCardDetailsInnerRoute.Details -> TangemPayCardPageScreenComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = params, tokenReceiveComponentFactory = tokenReceiveComponentFactory, ) - TangemPayDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( + TangemPayCardDetailsInnerRoute.ChangePIN -> TangemPayChangePinComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = TangemPayDetailsContainerComponent.Params( userWalletId = params.userWalletId, config = params.config, ), ) - TangemPayDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( + TangemPayCardDetailsInnerRoute.ChangePINSuccess -> TangemPayChangePinSuccessComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), ) - TangemPayDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( + TangemPayCardDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = TangemPayDetailsContainerComponent.Params( userWalletId = params.userWalletId, config = params.config, ), ) - TangemPayDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( + TangemPayCardDetailsInnerRoute.EditCardDisplayName -> TangemPayEditDisplayNameComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = TangemPayDetailsContainerComponent.Params( userWalletId = params.userWalletId, config = params.config, ), ) - TangemPayDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent( + TangemPayCardDetailsInnerRoute.LimitSetup -> TangemPayCardLimitSetupComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), params = TangemPayDetailsContainerComponent.Params( userWalletId = params.userWalletId, config = params.config, ), ) - TangemPayDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent( + TangemPayCardDetailsInnerRoute.LimitSetupSuccess -> TangemPayCardLimitSetupSuccessComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), ) } @@ -106,11 +112,10 @@ internal class DefaultTangemPayCardPageComponent @AssistedInject constructor( } } + data class Params(val userWalletId: UserWalletId, val config: TangemPayDetailsConfig) + @AssistedFactory - interface Factory : TangemPayCardPageComponent.Factory { - override fun create( - context: AppComponentContext, - params: TangemPayCardPageComponent.Params, - ): DefaultTangemPayCardPageComponent + interface Factory : ComponentFactory { + override fun create(context: AppComponentContext, params: Params): TangemPayCardPageComponent } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt index 039bcab003..9d3e29f799 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -17,7 +17,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent -import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation +import com.tangem.features.tangempay.entity.TangemPayCardNavigation import com.tangem.features.tangempay.model.TangemPayCardPageModel import com.tangem.features.tangempay.ui.TangemPayCardPageScreen import com.tangem.features.tokenreceive.TokenReceiveComponent @@ -45,7 +45,7 @@ internal class TangemPayCardPageScreenComponent( private val bottomSheetSlot = childSlot( source = model.bottomSheetNavigation, - serializer = TangemPayDetailsNavigation.serializer(), + serializer = TangemPayCardNavigation.serializer(), handleBackButton = false, childFactory = ::bottomSheetChild, ) @@ -69,12 +69,12 @@ internal class TangemPayCardPageScreenComponent( } private fun bottomSheetChild( - navigation: TangemPayDetailsNavigation, + navigation: TangemPayCardNavigation, componentContext: ComponentContext, ): ComposableBottomSheetComponent { val context = childByContext(componentContext) return when (navigation) { - is TangemPayDetailsNavigation.ViewPinCode -> TangemPayViewPinComponent( + is TangemPayCardNavigation.ViewPinCode -> TangemPayViewPinComponent( appComponentContext = context, params = TangemPayViewPinComponent.Params( walletId = navigation.userWalletId, @@ -82,7 +82,7 @@ internal class TangemPayCardPageScreenComponent( listener = model, ), ) - is TangemPayDetailsNavigation.ReissueCard -> TangemPayReissueCardComponent( + is TangemPayCardNavigation.ReissueCard -> TangemPayReissueCardComponent( appComponentContext = context, params = TangemPayReissueCardComponent.Params( listener = model, @@ -90,7 +90,7 @@ internal class TangemPayCardPageScreenComponent( cardId = params.config.cardId, ), ) - is TangemPayDetailsNavigation.AddFunds -> TangemPayAddFundsComponent( + is TangemPayCardNavigation.AddFunds -> TangemPayAddFundsComponent( appComponentContext = context, params = TangemPayAddFundsComponent.Params( listener = model, @@ -101,14 +101,13 @@ internal class TangemPayCardPageScreenComponent( chainId = navigation.chainId, ), ) - is TangemPayDetailsNavigation.Receive -> tokenReceiveComponentFactory.create( + is TangemPayCardNavigation.Receive -> tokenReceiveComponentFactory.create( context = context, params = TokenReceiveComponent.Params( config = navigation.config, onDismiss = model.bottomSheetNavigation::dismiss, ), ) - else -> error("Unsupported bottom sheet navigation: $navigation") } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt index d47fcd6737..eccf167f98 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayChangePinSuccessComponent.kt @@ -2,10 +2,10 @@ package com.tangem.features.tangempay.components import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute import androidx.compose.ui.Modifier import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.features.tangempay.ui.TangemPayChangePinCodeSuccessScreen internal class TangemPayChangePinSuccessComponent( @@ -19,6 +19,6 @@ internal class TangemPayChangePinSuccessComponent( } private fun backToDetails() { - router.popTo(route = TangemPayDetailsInnerRoute.Details) + router.popTo(route = TangemPayCardDetailsInnerRoute.Details) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index b59577ebe8..806f7c1980 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -16,8 +16,6 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.components.NavigationBar3ButtonsScrim import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.tangempay.components.cardDetails.DefaultTangemPayCardDetailsBlockComponent -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.express.ExpressTransactionsComponentProvider import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent @@ -49,14 +47,6 @@ internal class TangemPayDetailsComponent( ), ) - private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( - appComponentContext = child("cardDetailsBlockComponent"), - params = TangemPayCardDetailsBlockComponent.Params( - params = params, - isDisplayCardNameEnabled = false, - ), - ) - private val expressTransactionsComponent by lazy { expressTransactionsComponentProvider.create( appComponentContext = child("expressTransactionsComponent"), @@ -81,7 +71,6 @@ internal class TangemPayDetailsComponent( TangemPayDetailsScreen( state = state, txHistoryComponent = txHistoryComponent, - cardDetailsBlockComponent = cardDetailsBlockComponent, expressTransactionsComponent = expressTransactionsComponent, modifier = modifier, ) @@ -122,15 +111,6 @@ internal class TangemPayDetailsComponent( listener = model, ), ) - is TangemPayDetailsNavigation.ViewPinCode -> TangemPayViewPinComponent( - appComponentContext = context, - params = TangemPayViewPinComponent.Params( - walletId = navigation.userWalletId, - cardId = navigation.cardId, - listener = model, - ), - ) - else -> error("Unsupported bottom sheet navigation: $navigation") } } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt index df71114f7c..de293a174d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsFeatureModule.kt @@ -1,8 +1,6 @@ package com.tangem.features.tangempay.di -import com.tangem.features.tangempay.components.DefaultTangemPayCardPageComponent import com.tangem.features.tangempay.components.DefaultTangemPayDetailsContainerComponent -import com.tangem.features.tangempay.components.TangemPayCardPageComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.model.listener.DefaultCardDetailsEventListener @@ -22,12 +20,6 @@ internal interface TangemPayDetailsFeatureModule { factory: DefaultTangemPayDetailsContainerComponent.Factory, ): TangemPayDetailsContainerComponent.Factory - @Binds - @Singleton - fun bindTangemPayCardPageComponentFactory( - factory: DefaultTangemPayCardPageComponent.Factory, - ): TangemPayCardPageComponent.Factory - @Binds @Singleton fun bindCardDetailsEventListener(impl: DefaultCardDetailsEventListener): CardDetailsEventListener diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt new file mode 100644 index 0000000000..7e527748ee --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardNavigation.kt @@ -0,0 +1,30 @@ +package com.tangem.features.tangempay.entity + +import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.serialization.SerializedBigDecimal +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayCardNavigation { + @Serializable + data class ViewPinCode( + val userWalletId: UserWalletId, + val cardId: String, + ) : TangemPayCardNavigation() + + @Serializable + data object ReissueCard : TangemPayCardNavigation() + + @Serializable + data class AddFunds( + val walletId: UserWalletId, + val cryptoBalance: SerializedBigDecimal, + val fiatBalance: SerializedBigDecimal, + val depositAddress: String, + val chainId: Int, + ) : TangemPayCardNavigation() + + @Serializable + data class Receive(val config: TokenReceiveConfig) : TangemPayCardNavigation() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt index fdc5357eae..86009f09f0 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsNavigation.kt @@ -26,13 +26,4 @@ internal sealed class TangemPayDetailsNavigation { val transaction: TangemPayTxHistoryItem, val isBalanceHidden: Boolean, ) : TangemPayDetailsNavigation() - - @Serializable - data class ViewPinCode( - val userWalletId: UserWalletId, - val cardId: String, - ) : TangemPayDetailsNavigation() - - @Serializable - data object ReissueCard : TangemPayDetailsNavigation() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 8fb38c33bc..ca79de1c95 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -1,19 +1,15 @@ package com.tangem.features.tangempay.entity -import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem -import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.model.transformers.TangemPayCardFrozenStateConverter import com.tangem.features.tangempay.utils.TangemPayDetailIntents import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList @Suppress("LongParameterList") internal class TangemPayDetailsStateFactory( @@ -21,53 +17,26 @@ internal class TangemPayDetailsStateFactory( private val onOpenMenu: () -> Unit, private val intents: TangemPayDetailIntents, private val cardFrozenState: TangemPayCardFrozenState, - private val converter: TangemPayCardFrozenStateConverter, ) { @Suppress("LongMethod") - fun getInitialState(): TangemPayDetailsUM { - val cardFrozenStateItem = when (cardFrozenState) { - is TangemPayCardFrozenState.Pending -> null - is TangemPayCardFrozenState.Frozen -> TangemPayDetailsTopBarMenuItem( - type = TangemPayDetailsTopBarMenuItemType.UnfreezeCard, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference(R.string.tangempay_card_details_unfreeze_card), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = intents::onClickUnfreezeCard, - ), - ) - is TangemPayCardFrozenState.Unfrozen -> TangemPayDetailsTopBarMenuItem( - type = TangemPayDetailsTopBarMenuItemType.FreezeCard, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference(R.string.tangempay_card_details_freeze_card), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = intents::onClickFreezeCard, - ), - ) - } + fun getInitialState(cardNumberEnd: String): TangemPayDetailsUM { return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, onOpenMenu = onOpenMenu, - items = listOfNotNull( - TangemPayDetailsTopBarMenuItem( - type = TangemPayDetailsTopBarMenuItemType.ChangePin, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference(R.string.tangempay_card_details_pin_code), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = intents::onClickPinCode, - ), + items = persistentListOf( + TangemDropdownMenuItem( + title = resourceReference(R.string.tangem_pay_terms_limits), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = intents::onClickTermsAndLimits, ), - TangemPayDetailsTopBarMenuItem( - type = TangemPayDetailsTopBarMenuItemType.TermsAndLimits, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference(R.string.tangem_pay_terms_limits), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = intents::onClickTermsAndLimits, - ), + TangemDropdownMenuItem( + title = resourceReference(R.string.tangempay_pay_support), + textColor = themedColor { TangemTheme.colors.text.primary1 }, + onClick = intents::onContactSupportClicked, ), - cardFrozenStateItem, - ).toPersistentList(), + ), ), pullToRefreshConfig = PullToRefreshConfig( isRefreshing = false, @@ -88,21 +57,18 @@ internal class TangemPayDetailsStateFactory( isEnabled = cardFrozenState == TangemPayCardFrozenState.Unfrozen, ), ), + cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState( + cards = persistentListOf( + TangemPayDetailsBalanceBlockState.Card( + lastDigits = cardNumberEnd, + onClick = intents::onCardClick, + ), + ), + onAddCardClick = intents::onAddCardClick, + ), ), - addToWalletBlockState = null, isBalanceHidden = false, addFundsEnabled = true, - cardFrozenState = converter.convert(cardFrozenState), - betaNotificationConfig = NotificationConfig( - title = resourceReference(R.string.tangem_pay_beta_notification_title), - subtitle = resourceReference(R.string.tangem_pay_beta_notification_subtitle), - iconResId = R.drawable.img_visa_notification, - buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( - text = resourceReference(R.string.common_contact_support), - onClick = intents::onContactSupportClicked, - ), - iconSize = 36.dp, - ), ) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt index bd4daa10c9..9675f1b31d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsTopBarConfig.kt @@ -6,17 +6,5 @@ import kotlinx.collections.immutable.ImmutableList internal data class TangemPayDetailsTopBarConfig( val onBackClick: () -> Unit, val onOpenMenu: () -> Unit, - val items: ImmutableList?, -) - -internal data class TangemPayDetailsTopBarMenuItem( - val type: TangemPayDetailsTopBarMenuItemType, - val dropdownItem: TangemDropdownMenuItem, -) - -internal enum class TangemPayDetailsTopBarMenuItemType { - ChangePin, - TermsAndLimits, - FreezeCard, - UnfreezeCard, -} \ No newline at end of file + val items: ImmutableList, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 7eb7b01df6..b201d755e3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -2,7 +2,6 @@ package com.tangem.features.tangempay.entity import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig -import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.model.CardDataType @@ -12,11 +11,8 @@ internal data class TangemPayDetailsUM( val topBarConfig: TangemPayDetailsTopBarConfig, val pullToRefreshConfig: PullToRefreshConfig, val balanceBlockState: TangemPayDetailsBalanceBlockState, - val addToWalletBlockState: AddToWalletBlockState?, val isBalanceHidden: Boolean, val addFundsEnabled: Boolean, - val cardFrozenState: CardFrozenState, - val betaNotificationConfig: NotificationConfig, ) internal data class TangemPayCardDetailsUM( @@ -55,26 +51,27 @@ internal sealed interface DisplayNameState { internal sealed class TangemPayDetailsBalanceBlockState { abstract val actionButtons: ImmutableList + abstract val cardsBlockState: CardsBlockState data class Loading( override val actionButtons: ImmutableList, + override val cardsBlockState: CardsBlockState, ) : TangemPayDetailsBalanceBlockState() data class Content( override val actionButtons: ImmutableList, + override val cardsBlockState: CardsBlockState, val fiatBalance: String, val isBalanceFlickering: Boolean, ) : TangemPayDetailsBalanceBlockState() data class Error( override val actionButtons: ImmutableList, + override val cardsBlockState: CardsBlockState, ) : TangemPayDetailsBalanceBlockState() -} -sealed class CardFrozenState { - data object Pending : CardFrozenState() - data class Frozen(val onUnfreeze: () -> Unit) : CardFrozenState() - data object Unfrozen : CardFrozenState() + data class CardsBlockState(val cards: ImmutableList, val onAddCardClick: () -> Unit) + data class Card(val lastDigits: String, val onClick: () -> Unit) } internal data class AddToWalletBlockState( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index 509ea0514e..5f3941838b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -22,7 +22,7 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -146,7 +146,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( }, ifRight = { uiState.update { state -> state.copy(isSubmitButtonLoading = false) } - router.push(TangemPayDetailsInnerRoute.LimitSetupSuccess) + router.push(TangemPayCardDetailsInnerRoute.LimitSetupSuccess) }, ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt index fd424be559..a7c95a2eb2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupSuccessComponent.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute internal class TangemPayCardLimitSetupSuccessComponent( appComponentContext: AppComponentContext, @@ -21,6 +21,6 @@ internal class TangemPayCardLimitSetupSuccessComponent( } private fun backToDetails() { - router.popTo(route = TangemPayDetailsInnerRoute.Details) + router.popTo(route = TangemPayCardDetailsInnerRoute.Details) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index 13025f10a1..9bcf1fcdd1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -19,8 +19,10 @@ import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactor import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.model.listener.CardDetailsEvent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener -import com.tangem.features.tangempay.model.transformers.* -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.model.transformers.DetailsHiddenStateTransformer +import com.tangem.features.tangempay.model.transformers.DetailsRevealProgressStateTransformer +import com.tangem.features.tangempay.model.transformers.DetailsRevealedStateTransformer +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -131,7 +133,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } private fun startEditingDisplayName() { - router.push(TangemPayDetailsInnerRoute.EditCardDisplayName) + router.push(TangemPayCardDetailsInnerRoute.EditCardDisplayName) } private fun copyData(text: String, type: CardDataType) { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 2789a75e07..5bee5538b2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -39,7 +39,7 @@ import com.tangem.features.tangempay.components.TangemPayCardPageComponent import com.tangem.features.tangempay.components.ViewPinListener import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.* -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.features.tangempay.utils.TangemPayMessagesFactory import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder @@ -78,7 +78,7 @@ internal class TangemPayCardPageModel @Inject constructor( ), ) - val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val bottomSheetNavigation: SlotNavigation = SlotNavigation() // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed init { @@ -99,7 +99,7 @@ internal class TangemPayCardPageModel @Inject constructor( val symbol = getJavaCurrencyByCode(status.currencyCode).symbol fiat(status.currencyCode, symbol) }, - onChangeClick = { router.push(TangemPayDetailsInnerRoute.LimitSetup) }, + onChangeClick = { router.push(TangemPayCardDetailsInnerRoute.LimitSetup) }, ) } else { TangemPayDailyLimitBlockState.Error @@ -131,10 +131,10 @@ internal class TangemPayCardPageModel @Inject constructor( private fun onClickChangePIN(isPinSet: Boolean) { if (!isPinSet) { - router.push(TangemPayDetailsInnerRoute.ChangePIN) + router.push(TangemPayCardDetailsInnerRoute.ChangePIN) } else { bottomSheetNavigation.activate( - TangemPayDetailsNavigation.ViewPinCode( + TangemPayCardNavigation.ViewPinCode( userWalletId = params.userWalletId, cardId = params.config.cardId, ), @@ -153,7 +153,7 @@ internal class TangemPayCardPageModel @Inject constructor( private fun onClickReissueCard() { analytics.send(TangemPayAnalyticsEvents.ReplaceCardClicked()) - bottomSheetNavigation.activate(TangemPayDetailsNavigation.ReissueCard) + bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard) } override fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) { @@ -182,7 +182,7 @@ internal class TangemPayCardPageModel @Inject constructor( return@launch } bottomSheetNavigation.activate( - TangemPayDetailsNavigation.AddFunds( + TangemPayCardNavigation.AddFunds( walletId = params.userWalletId, fiatBalance = balance.fiatBalance, cryptoBalance = balance.cryptoBalance, @@ -202,7 +202,7 @@ internal class TangemPayCardPageModel @Inject constructor( showMemoDisclaimer = false, receiveAddress = data.receiveAddress, ) - bottomSheetNavigation.activate(TangemPayDetailsNavigation.Receive(config)) + bottomSheetNavigation.activate(TangemPayCardNavigation.Receive(config)) } override fun onClickSwap(data: TangemPayTopUpData) { @@ -282,7 +282,7 @@ internal class TangemPayCardPageModel @Inject constructor( } private fun onClickAddToWallet() { - router.push(TangemPayDetailsInnerRoute.AddToWallet) + router.push(TangemPayCardDetailsInnerRoute.AddToWallet) } private fun onClickCloseBanner() { @@ -294,7 +294,7 @@ internal class TangemPayCardPageModel @Inject constructor( override fun onClickChangePin() { bottomSheetNavigation.dismiss() - router.push(TangemPayDetailsInnerRoute.ChangePIN) + router.push(TangemPayCardDetailsInnerRoute.ChangePIN) } override fun onDismissViewPin() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt index de7acccebe..01ff0e469d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayChangePinModel.kt @@ -16,12 +16,14 @@ import com.tangem.features.tangempay.components.TangemPayDetailsContainerCompone import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayChangePinUM import com.tangem.features.tangempay.model.transformers.PinCodeChangeTransformer -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.transformer.update -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import com.tangem.utils.logging.TangemLogger +import com.tangem.utils.transformer.update +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @Stable @@ -69,7 +71,7 @@ internal class TangemPayChangePinModel @Inject constructor( } SetPinResult.SUCCESS -> { analytics.send(TangemPayAnalyticsEvents.ChangePinSuccessShown()) - router.push(TangemPayDetailsInnerRoute.ChangePINSuccess) + router.push(TangemPayCardDetailsInnerRoute.ChangePINSuccess) } SetPinResult.DECRYPTION_ERROR, SetPinResult.UNKNOWN_ERROR, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 9de0999957..6e32517234 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -15,8 +15,6 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig.ShowRefreshState -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType @@ -30,22 +28,22 @@ import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayWithdrawRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents -import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.domain.visa.model.TangemPayTxHistoryItem import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.tangempay.TangemPayConstants import com.tangem.features.tangempay.components.AddFundsListener import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent -import com.tangem.features.tangempay.components.ViewPinListener -import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayDetailsErrorType import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.features.tangempay.model.listener.CardDetailsEvent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener -import com.tangem.features.tangempay.model.transformers.* -import com.tangem.features.tangempay.navigation.TangemPayDetailsInnerRoute +import com.tangem.features.tangempay.model.transformers.DetailBalanceVisibilityTransformer +import com.tangem.features.tangempay.model.transformers.DetailsBalanceTransformer +import com.tangem.features.tangempay.model.transformers.TangemPayDetailsRefreshTransformer +import com.tangem.features.tangempay.model.transformers.TangemPayFreezeUnfreezeStateTransformer +import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.utils.TangemPayDetailIntents import com.tangem.features.tangempay.utils.TangemPayMessagesFactory import com.tangem.features.tangempay.utils.TangemPayTxHistoryUiActions @@ -84,25 +82,22 @@ internal class TangemPayDetailsModel @Inject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, private val expressTransactionsEventListener: ExpressTransactionsEventListener, -) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener, ViewPinListener { +) : Model(), TangemPayTxHistoryUiActions, TangemPayDetailIntents, AddFundsListener { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() - private val cardFrozenStateConverter = TangemPayCardFrozenStateConverter(onUnfreezeClick = ::onClickUnfreezeCard) private val stateFactory = TangemPayDetailsStateFactory( onBack = router::pop, onOpenMenu = ::onOpenMenu, intents = this, cardFrozenState = params.config.cardFrozenState, - converter = cardFrozenStateConverter, ) val uiState: StateFlow - field = MutableStateFlow(stateFactory.getInitialState()) + field = MutableStateFlow(stateFactory.getInitialState(cardNumberEnd = params.config.cardNumberEnd)) private val refreshStateJobHolder = JobHolder() private val fetchBalanceJobHolder = JobHolder() - private val addToWalletBannerJobHolder = JobHolder() private var balance: TangemPayCardBalance? = null @@ -116,7 +111,6 @@ internal class TangemPayDetailsModel @Inject constructor( init { analytics.send(TangemPayAnalyticsEvents.MainScreenOpened()) handleBalanceHiding() - fetchAddToWalletBanner() fetchBalance() subscribeToCardFrozenState() } @@ -136,123 +130,10 @@ internal class TangemPayDetailsModel @Inject constructor( private fun subscribeToCardFrozenState() { cardDetailsRepository .cardFrozenState(params.config.cardId) - .onEach { state -> - uiState.update( - TangemPayFreezeUnfreezeStateTransformer( - cardFrozenState = state, - onFreezeClick = ::onClickFreezeCard, - onUnfreezeClick = ::onClickUnfreezeCard, - converter = cardFrozenStateConverter, - ), - ) - } + .onEach { uiState.update(TangemPayFreezeUnfreezeStateTransformer(cardFrozenState = it)) } .launchIn(modelScope) } - override fun onClickPinCode() { - analytics.send(TangemPayAnalyticsEvents.PinCodeClicked()) - if (!params.config.isPinSet) { - router.push(TangemPayDetailsInnerRoute.ChangePIN) - } else { - bottomSheetNavigation.activate( - TangemPayDetailsNavigation.ViewPinCode( - userWalletId = params.userWalletId, - cardId = params.config.cardId, - ), - ) - } - } - - override fun onClickFreezeCard() { - analytics.send(TangemPayAnalyticsEvents.FreezeCardClicked()) - uiMessageSender.send(TangemPayMessagesFactory.createFreezeCardMessage(onFreezeClicked = ::freezeCard)) - analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmShown()) - } - - override fun onClickUnfreezeCard() { - analytics.send(TangemPayAnalyticsEvents.UnfreezeCardClicked()) - uiMessageSender.send(TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard)) - analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmShown()) - } - - private fun freezeCard() { - analytics.send(TangemPayAnalyticsEvents.FreezeCardConfirmClicked()) - modelScope.launch { - val result = try { - cardDetailsRepository.freezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) - } catch (e: Exception) { - TangemLogger.e("Error", e) - return@launch - } - result - .onLeft { - uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed))) - } - .onRight { state -> - when (state) { - TangemPayCardFrozenState.Frozen -> { - uiMessageSender.send( - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)), - ) - uiState.update( - TangemPayFreezeUnfreezeStateTransformer( - cardFrozenState = state, - onFreezeClick = ::onClickFreezeCard, - onUnfreezeClick = ::onClickUnfreezeCard, - converter = cardFrozenStateConverter, - ), - ) - } - TangemPayCardFrozenState.Unfrozen -> { - uiMessageSender.send( - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)), - ) - } - TangemPayCardFrozenState.Pending -> Unit // TODO [REDACTED_JIRA] - } - } - } - } - - private fun unfreezeCard() { - analytics.send(TangemPayAnalyticsEvents.UnfreezeCardConfirmClicked()) - modelScope.launch { - val result = try { - cardDetailsRepository.unfreezeCard(userWalletId = params.userWalletId, cardId = params.config.cardId) - } catch (e: Exception) { - TangemLogger.e("Error", e) - return@launch - } - result - .onLeft { - uiMessageSender.send(SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed))) - } - .onRight { state -> - when (state) { - TangemPayCardFrozenState.Unfrozen -> { - uiMessageSender.send( - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)), - ) - uiState.update( - TangemPayFreezeUnfreezeStateTransformer( - cardFrozenState = state, - onFreezeClick = ::onClickFreezeCard, - onUnfreezeClick = ::onClickUnfreezeCard, - converter = cardFrozenStateConverter, - ), - ) - } - TangemPayCardFrozenState.Frozen -> { - uiMessageSender.send( - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)), - ) - } - TangemPayCardFrozenState.Pending -> Unit // TODO [REDACTED_JIRA] - } - } - } - } - override fun onClickAddFunds() { analytics.send(TangemPayAnalyticsEvents.AddFundsClicked()) val currentBalance = balance @@ -352,26 +233,7 @@ internal class TangemPayDetailsModel @Inject constructor( }.launchIn(modelScope) } - private fun fetchAddToWalletBanner() { - modelScope.launch { - val isDone = try { - cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true - } catch (e: Exception) { - TangemLogger.e("Error", e) - return@launch - } - uiState.update( - transformer = DetailsAddToWalletBannerTransformer( - onClickBanner = ::onClickAddToWalletBlock, - onClickCloseBanner = ::onClickCloseAddToWalletBlock, - isDone = isDone, - ), - ) - }.saveIn(addToWalletBannerJobHolder) - } - override fun onContactSupportClicked() { - analytics.send(TangemPayAnalyticsEvents.GoToSupportOnBetaBannerClicked()) analytics.send(Basic.ButtonSupport(source = AnalyticsParam.ScreensSources.TangemPay)) modelScope.launch { sendFeedbackEmailUseCase.invoke( @@ -394,28 +256,6 @@ internal class TangemPayDetailsModel @Inject constructor( }.saveIn(refreshStateJobHolder) } - private fun onClickAddToWalletBlock() { - analytics.send(TangemPayAnalyticsEvents.AddToWalletClicked()) - router.push(TangemPayDetailsInnerRoute.AddToWallet) - } - - private fun onClickCloseAddToWalletBlock() { - modelScope.launch { - try { - cardDetailsRepository.setAddToWalletAsDone(params.userWalletId) - } catch (e: Exception) { - TangemLogger.e("Error", e) - } - uiState.update( - transformer = DetailsAddToWalletBannerTransformer( - onClickBanner = ::onClickAddToWalletBlock, - onClickCloseBanner = ::onClickCloseAddToWalletBlock, - isDone = true, - ), - ) - }.saveIn(addToWalletBannerJobHolder) - } - private fun onOpenMenu() { analytics.send(TangemPayAnalyticsEvents.CardSettingsClicked()) } @@ -456,16 +296,6 @@ internal class TangemPayDetailsModel @Inject constructor( bottomSheetNavigation.dismiss() } - override fun onClickChangePin() { - bottomSheetNavigation.dismiss() - analytics.send(TangemPayAnalyticsEvents.ChangePinOnCurrentPinClicked()) - router.push(TangemPayDetailsInnerRoute.ChangePIN) - } - - override fun onDismissViewPin() { - bottomSheetNavigation.dismiss() - } - override fun onTransactionClick(item: TangemPayTxHistoryItem) { val (type, status) = when (item) { is TangemPayTxHistoryItem.Collateral -> "collateral" to "unknown" @@ -487,6 +317,14 @@ internal class TangemPayDetailsModel @Inject constructor( urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) } + override fun onCardClick() { + router.push(TangemPayAccountDetailsInnerRoute.CardDetails) + } + + override fun onAddCardClick() { + uiMessageSender.send(message = TangemPayMessagesFactory.createFutureFeature()) + } + private fun showBottomSheetError(type: TangemPayDetailsErrorType) { uiMessageSender.send(message = TangemPayMessagesFactory.createErrorMessage(errorType = type)) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt deleted file mode 100644 index 3fe7bddbf8..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.features.tangempay.model.transformers - -import com.tangem.features.tangempay.entity.AddToWalletBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import com.tangem.utils.transformer.Transformer - -internal class DetailsAddToWalletBannerTransformer( - private val onClickBanner: () -> Unit, - private val onClickCloseBanner: () -> Unit, - private val isDone: Boolean, -) : Transformer { - - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - return prevState.copy( - addToWalletBlockState = if (isDone) { - null - } else { - AddToWalletBlockState(onClick = onClickBanner, onClickClose = onClickCloseBanner) - }, - ) - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt index d2da85c3d4..af0cec0bd5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsBalanceTransformer.kt @@ -22,19 +22,26 @@ internal class DetailsBalanceTransformer( override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { val balance = when (balance) { is Either.Left -> { - TangemPayDetailsBalanceBlockState.Error(actionButtons = persistentListOf()) + TangemPayDetailsBalanceBlockState.Error( + actionButtons = persistentListOf(), + cardsBlockState = prevState.balanceBlockState.cardsBlockState, + ) } is Either.Right -> { val cryptoCurrency = userWallet?.let { cryptoCurrencyFactory.create(userWallet, balance.value.chainId).getOrNull() } if (cryptoCurrency == null) { - TangemPayDetailsBalanceBlockState.Error(actionButtons = persistentListOf()) + TangemPayDetailsBalanceBlockState.Error( + actionButtons = persistentListOf(), + cardsBlockState = prevState.balanceBlockState.cardsBlockState, + ) } else { TangemPayDetailsBalanceBlockState.Content( isBalanceFlickering = false, fiatBalance = getFiatBalanceText(balance.value), actionButtons = prevState.balanceBlockState.actionButtons, + cardsBlockState = prevState.balanceBlockState.cardsBlockState, ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardFrozenStateConverter.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardFrozenStateConverter.kt deleted file mode 100644 index 1be3c5e765..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardFrozenStateConverter.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tangem.features.tangempay.model.transformers - -import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.features.tangempay.entity.CardFrozenState -import com.tangem.utils.converter.Converter - -internal class TangemPayCardFrozenStateConverter( - private val onUnfreezeClick: () -> Unit, -) : Converter { - - override fun convert(value: TangemPayCardFrozenState): CardFrozenState { - return when (value) { - TangemPayCardFrozenState.Unfrozen -> CardFrozenState.Unfrozen - TangemPayCardFrozenState.Frozen -> CardFrozenState.Frozen(onUnfreezeClick) - TangemPayCardFrozenState.Pending -> CardFrozenState.Pending - } - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt index 7fb2efcb5e..9ba594e731 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt @@ -1,77 +1,24 @@ package com.tangem.features.tangempay.model.transformers -import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.themedColor -import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItem -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItemType.FreezeCard -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarMenuItemType.UnfreezeCard import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.utils.transformer.Transformer -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList internal class TangemPayFreezeUnfreezeStateTransformer( private val cardFrozenState: TangemPayCardFrozenState, - private val onFreezeClick: () -> Unit, - private val onUnfreezeClick: () -> Unit, - private val converter: TangemPayCardFrozenStateConverter, ) : Transformer { - private val isCardFrozen: Boolean = when (cardFrozenState) { - is TangemPayCardFrozenState.Frozen -> true - is TangemPayCardFrozenState.Unfrozen, is TangemPayCardFrozenState.Pending -> false - } - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - val filteredItems = prevState.topBarConfig.items?.filterNot { - it.type == FreezeCard || it.type == UnfreezeCard - } - val dropdownMenuItems = createUpdatedMenuItems(filteredItems?.toPersistentList()) val balanceBlockState = if (prevState.balanceBlockState is TangemPayDetailsBalanceBlockState.Content) { val actionButtons = prevState.balanceBlockState.actionButtons.map { it.copy(isEnabled = cardFrozenState == TangemPayCardFrozenState.Unfrozen) } - prevState.balanceBlockState.copy( - actionButtons = actionButtons.toPersistentList(), - ) + prevState.balanceBlockState.copy(actionButtons = actionButtons.toPersistentList()) } else { prevState.balanceBlockState } - return prevState.copy( - topBarConfig = prevState.topBarConfig.copy(items = dropdownMenuItems), - cardFrozenState = converter.convert(cardFrozenState), - balanceBlockState = balanceBlockState, - ) - } - - private fun createUpdatedMenuItems( - items: ImmutableList?, - ): ImmutableList? { - return items - ?.plus(createMenuItemToAdd(cardFrozenState)) - ?.toPersistentList() - } - - private fun createMenuItemToAdd(cardFrozenState: TangemPayCardFrozenState): TangemPayDetailsTopBarMenuItem { - return TangemPayDetailsTopBarMenuItem( - type = if (isCardFrozen) UnfreezeCard else FreezeCard, - dropdownItem = TangemDropdownMenuItem( - title = resourceReference( - id = if (isCardFrozen) { - R.string.tangempay_card_details_unfreeze_card - } else { - R.string.tangempay_card_details_freeze_card - }, - ), - textColor = themedColor { TangemTheme.colors.text.primary1 }, - onClick = if (isCardFrozen) onUnfreezeClick else onFreezeClick, - isEnabled = cardFrozenState != TangemPayCardFrozenState.Pending, - ), - ) + return prevState.copy(balanceBlockState = balanceBlockState) } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt new file mode 100644 index 0000000000..6bc9172607 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt @@ -0,0 +1,13 @@ +package com.tangem.features.tangempay.navigation + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayAccountDetailsInnerRoute : Route { + @Serializable + data object AccountDetails : TangemPayAccountDetailsInnerRoute() + + @Serializable + data object CardDetails : TangemPayAccountDetailsInnerRoute() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt new file mode 100644 index 0000000000..38d9fb1d83 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayCardDetailsInnerRoute.kt @@ -0,0 +1,29 @@ +package com.tangem.features.tangempay.navigation + +import com.tangem.core.decompose.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class TangemPayCardDetailsInnerRoute : Route { + + @Serializable + data object Details : TangemPayCardDetailsInnerRoute() + + @Serializable + data object ChangePIN : TangemPayCardDetailsInnerRoute() + + @Serializable + data object ChangePINSuccess : TangemPayCardDetailsInnerRoute() + + @Serializable + data object AddToWallet : TangemPayCardDetailsInnerRoute() + + @Serializable + data object EditCardDisplayName : TangemPayCardDetailsInnerRoute() + + @Serializable + data object LimitSetup : TangemPayCardDetailsInnerRoute() + + @Serializable + data object LimitSetupSuccess : TangemPayCardDetailsInnerRoute() +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt deleted file mode 100644 index 89facef290..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayDetailsInnerRoute.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.features.tangempay.navigation - -import com.tangem.core.decompose.navigation.Route -import kotlinx.serialization.Serializable - -@Serializable -internal sealed class TangemPayDetailsInnerRoute : Route { - @Serializable - data object Details : TangemPayDetailsInnerRoute() - - @Serializable - data object ChangePIN : TangemPayDetailsInnerRoute() - - @Serializable - data object ChangePINSuccess : TangemPayDetailsInnerRoute() - - @Serializable - data object AddToWallet : TangemPayDetailsInnerRoute() - - @Serializable - data object EditCardDisplayName : TangemPayDetailsInnerRoute() - - @Serializable - data object LimitSetup : TangemPayDetailsInnerRoute() - - @Serializable - data object LimitSetupSuccess : TangemPayDetailsInnerRoute() -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 86e06e7ffe..a2b35908ce 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -2,17 +2,26 @@ package com.tangem.features.tangempay.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -21,34 +30,33 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEach import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.buttons.HorizontalActionChips import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.buttons.small.TangemIconButton import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu -import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.text.applyBladeBrush -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.orMaskWithStars import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TokenDetailsTopBarTestTags -import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent -import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent +import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTransactionsComponent import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.* +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf @@ -58,7 +66,6 @@ import kotlinx.collections.immutable.persistentListOf internal fun TangemPayDetailsScreen( state: TangemPayDetailsUM, txHistoryComponent: TangemPayTxHistoryComponent, - cardDetailsBlockComponent: TangemPayCardDetailsBlockComponent, expressTransactionsComponent: ExpressTransactionsComponent, modifier: Modifier = Modifier, ) { @@ -71,7 +78,6 @@ internal fun TangemPayDetailsScreen( val listState = rememberLazyListState() val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle() - val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle() val expressTransactionsBottomSheetState = expressState.bottomSheetSlot @@ -86,39 +92,12 @@ internal fun TangemPayDetailsScreen( bottom = TangemTheme.dimens.spacing16 + bottomBarHeight, ), ) { - item(TangemPayCardDetailsUM::class.java) { - cardDetailsBlockComponent.CardDetailsBlockContent( + item(key = "title") { + TangemPayTitle( modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 8.dp), - state = cardDetailsState, - ) - } - when (state.cardFrozenState) { - is CardFrozenState.Frozen -> item(CardFrozenState.Frozen::class.java) { - PrimaryButton( - modifier = Modifier - .animateItem() - .padding(horizontal = 16.dp) - .padding(top = 12.dp) - .fillMaxWidth(), - text = stringResourceSafe(R.string.tangempay_card_details_unfreeze_card), - onClick = state.cardFrozenState.onUnfreeze, - ) - } - else -> Unit - } - if (state.addToWalletBlockState != null) { - item( - key = AddToWalletBlockState::class.java, - content = { - TangemPayAddToWalletBlock( - state = state.addToWalletBlockState, - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing12) - .padding(horizontal = TangemTheme.dimens.spacing16), - ) - }, + .padding(horizontal = TangemTheme.dimens.spacing16) + .padding(top = 4.dp) + .fillMaxWidth(), ) } item( @@ -134,18 +113,6 @@ internal fun TangemPayDetailsScreen( ) }, ) - item( - key = "TANGEM_PAY_IS_IN_BETA", - content = { - TangemPayBetaBlock( - modifier = modifier - .padding(horizontal = TangemTheme.dimens.spacing16) - .padding(top = 12.dp) - .fillMaxWidth(), - config = state.betaNotificationConfig, - ) - }, - ) with(expressTransactionsComponent) { expressTransactionsContent( state = expressState.transactionsToDisplay, @@ -162,8 +129,59 @@ internal fun TangemPayDetailsScreen( } @Composable -private fun TangemPayBetaBlock(config: NotificationConfig, modifier: Modifier = Modifier) { - Notification(modifier = modifier, config = config) +private fun TangemPayTitle(modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Text( + text = stringResourceSafe(R.string.tangempay_payment_account), + style = TangemTheme.typography.head, + color = TangemTheme.colors.text.primary1, + maxLines = 1, + ) + TangemPaySubtitle() + } +} + +@Suppress("MagicNumber") +@Composable +private fun TangemPaySubtitle(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(modifier = Modifier.wrapContentWidth()) { + Icon( + painter = painterResource(id = R.drawable.ic_polygon_22), + contentDescription = null, + tint = TangemTheme.colors.text.constantWhite, + modifier = Modifier + .padding(start = TangemTheme.dimens.spacing12) + .border(width = 2.dp, color = TangemTheme.colors.background.secondary, shape = CircleShape) + .padding(2.dp) + .clip(CircleShape) + .background(Color(0xFF8247E5)) + .size(16.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_ICONS), + ) + Image( + painter = painterResource(id = R.drawable.img_usdc_16), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .border(width = 2.dp, color = TangemTheme.colors.background.secondary, shape = CircleShape) + .padding(2.dp) + .clip(CircleShape) + .size(16.dp) + .testTag(WalletConnectBottomSheetTestTags.NETWORKS_ICONS), + ) + } + Text( + modifier = Modifier.align(Alignment.CenterVertically), + text = stringResourceSafe(R.string.tangempay_usdc_on_polygon_network), + style = TangemTheme.typography.caption2, + color = TangemTheme.colors.text.tertiary, + maxLines = 1, + ) + } } // region Balance block @@ -183,7 +201,7 @@ private fun TangemPayDetailsBalanceBlock( ) { Text( modifier = Modifier.padding(start = 12.dp), - text = stringResourceSafe(R.string.tangempay_title), + text = stringResourceSafe(R.string.common_balance_title), color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.subtitle2, ) @@ -192,6 +210,12 @@ private fun TangemPayDetailsBalanceBlock( state = state, isBalanceHidden = isBalanceHidden, ) + CardsBlockRow( + modifier = Modifier + .wrapContentSize() + .padding(horizontal = 12.dp, vertical = 8.dp), + cardsBlockState = state.cardsBlockState, + ) if (state.actionButtons.isNotEmpty()) { HorizontalActionChips( modifier = Modifier.padding(top = 12.dp), @@ -203,6 +227,51 @@ private fun TangemPayDetailsBalanceBlock( } } +@Composable +private fun CardsBlockRow( + cardsBlockState: TangemPayDetailsBalanceBlockState.CardsBlockState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val itemsModifier = Modifier.size(width = 48.dp, height = 32.dp) + cardsBlockState.cards.fastForEach { card -> + TangemPayCardItem(modifier = itemsModifier, card = card) + } + TangemIconButton( + modifier = itemsModifier, + onClick = cardsBlockState.onAddCardClick, + iconRes = R.drawable.ic_plus_24, + shape = RoundedCornerShape(4.dp), + ) + } +} + +@Composable +private fun TangemPayCardItem(card: TangemPayDetailsBalanceBlockState.Card, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = card.onClick), + ) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(R.drawable.img_visa_card_48x32), + contentDescription = null, + ) + Text( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(4.dp, bottom = 2.dp), + text = card.lastDigits, + style = TangemTheme.typography.overline.copy(letterSpacing = 0.sp), + color = TangemTheme.colors.text.constantWhite, + ) + } +} + @Composable private fun FiatBalance( state: TangemPayDetailsBalanceBlockState, @@ -251,7 +320,7 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi }, title = {}, actions = { - AnimatedVisibility(visible = config.items != null && config.items.isNotEmpty()) { + AnimatedVisibility(visible = config.items.isNotEmpty()) { IconButton( onClick = { config.onOpenMenu() @@ -272,9 +341,9 @@ private fun TangemPayDetailsTopAppBar(config: TangemPayDetailsTopBarConfig, modi modifier = Modifier.background(TangemTheme.colors.background.primary), onDismissRequest = { showDropdownMenu = false }, content = { - config.items?.fastForEach { + config.items.fastForEach { item -> TangemDropdownItem( - item = it.dropdownItem, + item = item, dismissParent = { showDropdownMenu = false }, ) } @@ -302,19 +371,6 @@ private fun TangemPayDetailsScreenPreview( txHistoryComponent = PreviewTangemPayTxHistoryComponent( txHistoryUM = PreviewTangemPayTxHistoryComponent.contentUM, ), - cardDetailsBlockComponent = PreviewTangemPayCardDetailsBlockComponent( - TangemPayCardDetailsUM( - number = "•••• •••• •••• 1245", - numberShort = "*1245", - expiry = "••/••", - cvv = "•••", - onCopy = { _, _ -> }, - onClick = {}, - buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = null, - ), - ), expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), ) } @@ -323,7 +379,7 @@ private fun TangemPayDetailsScreenPreview( private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider( collection = listOf( TangemPayDetailsUM( - topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = null), + topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = persistentListOf()), pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), balanceBlockState = TangemPayDetailsBalanceBlockState.Content( actionButtons = persistentListOf( @@ -335,40 +391,29 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider }, - onClick = {}, - buttonText = TextReference.Res(R.string.tangempay_card_details_hide_text), - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = null, - ), - ), expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt index 9e96a75104..731154d2a1 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayDetailIntents.kt @@ -7,8 +7,7 @@ internal interface TangemPayDetailIntents { fun onRefreshSwipe(refreshState: ShowRefreshState) fun onClickAddFunds() fun onClickWithdraw() - fun onClickPinCode() - fun onClickFreezeCard() - fun onClickUnfreezeCard() fun onClickTermsAndLimits() + fun onCardClick() + fun onAddCardClick() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt index 5f8a35fffc..e10ee99a4e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt @@ -113,4 +113,20 @@ internal object TangemPayMessagesFactory { } } } + + fun createFutureFeature(): BottomSheetMessage { + return bottomSheetMessage { + infoBlock { + icon(R.drawable.ic_credit_card_add_24) { + backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Accent + } + title = resourceReference(R.string.tangempay_feature_will_be_available_soon) + body = resourceReference(R.string.tangempay_feature_will_be_available_soon_description) + } + primaryButton { + text = resourceReference(R.string.common_got_it) + onClick { closeBs() } + } + } + } } \ No newline at end of file From eb6d4806e657f4a6c4f01dd1045ca8c001bd58ac Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 17:03:15 +0400 Subject: [PATCH 125/206] Updated on 2026-08-14 --- .../addtoportfolio/AddToPortfolioManager.kt | 1 + .../AddToPortfolioPreselectedDataComponent.kt | 38 --- ...tAddToPortfolioPreselectedDataComponent.kt | 91 ------ .../di/AddToPortfolioComponentModule.kt | 7 - .../di/AddToPortfolioModelModule.kt | 6 - .../model/AddToPortfolioModel.kt | 308 ++++++++---------- .../AddToPortfolioPreselectedDataModel.kt | 300 ----------------- .../feed/components/FeedEntryChildFactory.kt | 6 +- .../components/earn/DefaultEarnComponent.kt | 17 +- .../components/feed/DefaultFeedComponent.kt | 14 +- .../components/feed/FeedBottomSheetRoute.kt | 2 - .../features/feed/model/earn/EarnModel.kt | 110 +++++-- .../earn/analytics/EarnAnalyticsEvent.kt | 7 +- .../UpdateMostlyUsedStateTransformer.kt | 6 +- .../statemanager/EarnListBatchFlowManager.kt | 6 +- .../feed/model/feed/FeedComponentModel.kt | 82 +++-- 16 files changed, 291 insertions(+), 710 deletions(-) delete mode 100644 features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.kt delete mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt delete mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt index 5b53e9abc2..b4d0f52a3f 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioManager.kt @@ -76,6 +76,7 @@ interface AddToPortfolioManager : AddToPortfolioManagerInternal { companion object { val DefaultMarket = Settings(shouldSkipTokenActionsScreen = false) val ChooseToken = Settings(shouldSkipTokenActionsScreen = true) + val Earn = Settings(shouldSkipTokenActionsScreen = true) } } diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.kt deleted file mode 100644 index 59b4f03918..0000000000 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/addtoportfolio/AddToPortfolioPreselectedDataComponent.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tangem.features.commonfeatures.api.addtoportfolio - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.serialization.Serializable - -interface AddToPortfolioPreselectedDataComponent : ComposableBottomSheetComponent { - - /** - * @param tokenToAdd token and preselected network (no network selector). - * @param callback callbacks for add-to-portfolio flow. - */ - data class Params( - val tokenToAdd: TokenToAdd, - val callback: Callback, - val analyticsParams: AnalyticsParams, - ) - - data class AnalyticsParams(val source: String) - - interface Callback { - fun onDismiss() - fun onSuccess(addedToken: CryptoCurrency, walletId: UserWalletId) - } - - @Serializable - data class TokenToAdd( - val network: TokenMarketInfo.Network, - val id: CryptoCurrency.RawID, - val name: String, - val symbol: String, - ) - - interface Factory : ComponentFactory -} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt deleted file mode 100644 index 103f0d4ee0..0000000000 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioPreselectedDataComponent.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.tangem.features.commonfeatures.impl.addtoportfolio - -import androidx.compose.runtime.Composable -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.stack.backStack -import com.arkivanov.decompose.router.stack.childStack -import com.arkivanov.decompose.router.stack.pop -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent -import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioPreselectedDataModel -import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultAddToPortfolioPreselectedDataComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted private val params: AddToPortfolioPreselectedDataComponent.Params, - portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory, - addTokenComponentFactory: AddTokenComponent.Factory, -) : AppComponentContext by context, AddToPortfolioPreselectedDataComponent { - - private val model: AddToPortfolioPreselectedDataModel = getOrCreateModel(params) - - private val portfolioSelectorComponent: PortfolioSelectorComponent = portfolioSelectorComponentFactory.create( - context = child("portfolioSelectorComponent"), - params = PortfolioSelectorComponent.Params( - portfolioFetcher = model.portfolioFetcher, - controller = model.portfolioSelectorController, - ), - ) - - private val addTokenComponent: AddTokenComponent = addTokenComponentFactory.create( - context = child("addTokenComponent"), - params = AddTokenComponent.Params( - eventBuilder = model.eventBuilder, - callbacks = model, - selectedPortfolio = model.selectedPortfolio, - selectedNetwork = model.selectedNetwork, - ), - ) - - private val childStack = childStack( - key = "addToPortfolioFromEarnStack", - handleBackButton = true, - source = model.navigation, - serializer = AddToPortfolioRoutes.serializer(), - initialStack = { model.currentStack }, - childFactory = { configuration, _ -> - contentChild(configuration) - }, - ) - - private fun onBack() { - if (childStack.backStack.isNotEmpty()) model.navigation.pop() else dismiss() - } - - override fun dismiss() { - params.callback.onDismiss() - } - - @Composable - override fun BottomSheet() { - AddToPortfolioBottomSheet( - childStack = childStack.subscribeAsState(), - onBack = ::onBack, - onDismiss = ::dismiss, - ) - } - - private fun contentChild(config: AddToPortfolioRoutes): ComposableContentComponent = when (config) { - AddToPortfolioRoutes.AddToken -> addTokenComponent - AddToPortfolioRoutes.PortfolioSelector -> portfolioSelectorComponent - AddToPortfolioRoutes.TokenActions -> ComposableContentComponent.EMPTY - AddToPortfolioRoutes.Empty -> ComposableContentComponent.EMPTY - is AddToPortfolioRoutes.NetworkSelector -> ComposableContentComponent.EMPTY - AddToPortfolioRoutes.UserPortfolio -> ComposableContentComponent.EMPTY - } - - @AssistedFactory - interface Factory : AddToPortfolioPreselectedDataComponent.Factory { - override fun create( - context: AppComponentContext, - params: AddToPortfolioPreselectedDataComponent.Params, - ): DefaultAddToPortfolioPreselectedDataComponent - } -} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt index c32baf5b2f..020655ca65 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioComponentModule.kt @@ -2,9 +2,7 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.di import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioComponent -import com.tangem.features.commonfeatures.impl.addtoportfolio.DefaultAddToPortfolioPreselectedDataComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.DefaultAddToPortfolioManager import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.DefaultUserPortfolioComponent import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.UserPortfolioComponent @@ -23,11 +21,6 @@ internal interface AddToPortfolioComponentModule { @Binds fun bindAddToPortfolioManagerFactory(factory: DefaultAddToPortfolioManager.Factory): AddToPortfolioManager.Factory - @Binds - fun bindAddToPortfolioPreselectedDataComponent( - factory: DefaultAddToPortfolioPreselectedDataComponent.Factory, - ): AddToPortfolioPreselectedDataComponent.Factory - @Binds fun bindUserPortfolioComponentFactory( factory: DefaultUserPortfolioComponent.Factory, diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt index a0ab0d1bf4..699b4df576 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/di/AddToPortfolioModelModule.kt @@ -3,7 +3,6 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioModel -import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioPreselectedDataModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddTokenModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.ChooseNetworkModel import com.tangem.features.commonfeatures.impl.addtoportfolio.model.TokenActionsModel @@ -28,11 +27,6 @@ internal interface AddToPortfolioModelModule { @ClassKey(AddToPortfolioModel::class) fun addToPortfolioModel(model: AddToPortfolioModel): Model - @Binds - @IntoMap - @ClassKey(AddToPortfolioPreselectedDataModel::class) - fun addToPortfolioPreselectedDataModel(model: AddToPortfolioPreselectedDataModel): Model - @Binds @IntoMap @ClassKey(TokenActionsModel::class) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 9c2305f233..62226b2e71 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -15,6 +15,7 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.message.ToastMessage +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetCryptoCurrencyActionsUseCaseV2 import com.tangem.domain.markets.GetTokenMarketCryptoCurrency import com.tangem.domain.markets.RawMarketToken @@ -60,6 +61,7 @@ internal class AddToPortfolioModel @Inject constructor( private val analyticsEventHandler: AnalyticsEventHandler, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val selectionResolver: AddToPortfolioInitialSelectionResolver, + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, userPortfolioStateControllerFactory: UserPortfolioStateController.Factory, ) : Model(), ChooseNetworkComponent.Callbacks by callbackDelegate, @@ -95,19 +97,13 @@ internal class AddToPortfolioModel @Inject constructor( onTokenSelected = { result -> addToPortfolioManager.onAddedTokenClick(result) }, ) - val featureData: Flow = combineFeatureData() - private val globalSelectedWallet: UserWallet? get() = getSelectedWalletSyncUseCase().getOrNull() .takeIf { it?.isMultiCurrency == true } init { navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } - if (designFeatureToggles.isRedesignEnabled) { - startRedesignAddToPortfolioFlow() - } else { - startLegacyAddToPortfolioFlow() - } + startRedesignAddToPortfolioFlow() } private fun replayMutableSharedFlow() = MutableSharedFlow( @@ -115,132 +111,43 @@ internal class AddToPortfolioModel @Inject constructor( onBufferOverflow = BufferOverflow.DROP_OLDEST, ) - @Suppress("LongMethod") - private fun startLegacyAddToPortfolioFlow() { - channelFlow { - fun finishSuccessFlow(result: AddToPortfolioManager.Result) { - addToPortfolioManager.onSuccessAdded(result) - channel.close() - } - - val featureDataFlow: StateFlow = featureData - .filterIsInstance() - .map { it.availableToAddData } - .distinctUntilChanged() - .stateIn(this) - val isAccountMode = portfolioSelectorController.isAccountModeSync() - - // use snapshot data, looks like we don’t need to remap at runtime - val data = featureDataFlow.value - - // init data flows, emits on user/code selection, updates state holder - val firstSelectedPortfolio = setupPortfolioFlow(data) - .onEach { selectedPortfolio.emit(it) } - val firstSelectedNetwork = setupNetworkFlow(firstSelectedPortfolio) - .onEach { selectedNetwork.emit(it) } - - val isSinglePortfolio = data.isSinglePortfolio - if (isSinglePortfolio) { - val accountId = data.availableToAddWallets.values.first() - .availableToAddAccounts.values.first() - .account.account.accountId - // force select a portfolio, triggers [selectedPortfolio] - portfolioSelectorController.selectAccount(accountId) - } else { - logAccountSelector(isAccountMode) - navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) - } - - val firstPartOfNavigation: Job = firstSelectedPortfolio - .onEach { portfolio -> - val isSingleAvailableNetwork = portfolio.account.isSingleNetwork - when { - // force select a network, triggers [selectedNetwork] - isSingleAvailableNetwork -> { - val singleNetwork = portfolio.account.availableToAddNetworks.first() - callbackDelegate.onNetworkSelected(singleNetwork) - } - // it's important to control root screen, UI depends on it(close/arrow icon) - isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) - else -> navigation.pushNew(routeToNetworkSelector(portfolio)) - } - } - .launchIn(this) - - // main flow that combine all require data - val allRequireForAdd = combine( - flow = firstSelectedNetwork, - flow2 = firstSelectedPortfolio, - transform = { a, b -> a to b }, - ) - - // suspend until all required data is selected - val firstPair = allRequireForAdd.first() - // line of navigation to AddToken screen is finished; cancel the job, select a new root screen - firstPartOfNavigation.cancel() - - val selectedNetworkName = firstPair.first.cryptoCurrency.network.name - analyticsEventHandler.send(event = eventBuilder.popupToConfirm(selectedNetworkName)) - navigation.replaceAll(AddToPortfolioRoutes.AddToken) - - var middleNavigationJob: Job? = null - // handle actions from AddToken screen - callbackDelegate.onChangeNetworkClick.receiveAsFlow() - .onEach { - middleNavigationJob?.cancel() - middleNavigationJob = changeNetworkNavigationFlow() - .launchIn(this) - val route = routeToNetworkSelector(selectedPortfolio.first()) - navigation.pushNew(route) - } - .launchIn(this) - // handle actions from AddToken screen - callbackDelegate.onChangePortfolioClick.receiveAsFlow() - .onEach { - middleNavigationJob?.cancel() - middleNavigationJob = changePortfolioNavigationFlow(data).launchIn(this) - logAccountSelector(isAccountMode) - navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) - } - .launchIn(this) - - // suspend until token is added - val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() - middleNavigationJob?.cancel() - val selectedPortfolio = selectedPortfolio.first() - analyticsEventHandler.send(eventBuilder.tokenAdded(addedToken.currency.network.name)) - if (!selectedPortfolio.account.account.account.isMainAccount) { - analyticsEventHandler.send(eventBuilder.addToNotMainAccount()) - } - val result = AddToPortfolioManager.Result( - wallet = selectedPortfolio.userWallet, - account = selectedPortfolio.account.account, - addedCurrency = addedToken, - ) - - messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) - - if (addToPortfolioManager.settings.shouldSkipTokenActionsScreen) { - finishSuccessFlow(result) - } else { - setupTokenActionsFlow(selectedPortfolio, addedToken) - .onEach { cryptoCurrencyData -> - tokenActionsData.emit(cryptoCurrencyData) - navigation.replaceAll(AddToPortfolioRoutes.TokenActions) - } - .onEmpty { finishSuccessFlow(result) } - .launchIn(this) - } - - callbackDelegate.onLaterClick.receiveAsFlow().first() - analyticsEventHandler.send(eventBuilder.getTokenLater()) - finishSuccessFlow(result) + private fun lineNavigationFlowToAddTokenScreen( + isAccountMode: Boolean, + data: AvailableToAddData, + firstSelectedPortfolioFlow: Flow, + ): Flow { + val isSinglePortfolio = data.isSinglePortfolio + if (isSinglePortfolio) { + val accountId = data.availableToAddWallets.values.first() + .availableToAddAccounts.values.first() + .account.account.accountId + // force select a portfolio, triggers [selectedPortfolio] + portfolioSelectorController.selectAccount(accountId) + } else { + logAccountSelector(isAccountMode) + navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) } - .catch { throwable -> - TangemLogger.e("Error", throwable) - addToPortfolioManager.onDismiss() + + return firstSelectedPortfolioFlow.onEach { portfolio -> + val isAvailableToAdd = portfolio.account.isAvailableToAdd + val isSingleAvailableNetwork = portfolio.account.isSingleNetwork + when { + // force select an added network, not allowed to add + // but its call AddToPortfolioManager.onAddedTokenClick callback + !isAvailableToAdd -> { + val singleNetwork = portfolio.account.addedMarketNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } + // force select a network, triggers [selectedNetwork] + isSingleAvailableNetwork -> { + val singleNetwork = portfolio.account.availableToAddNetworks.first() + callbackDelegate.onNetworkSelected(singleNetwork) + } + // it's important to control root screen, UI depends on it(close/arrow icon) + isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) + else -> navigation.pushNew(routeToNetworkSelector(portfolio)) } - .launchIn(modelScope) + } } @Suppress("LongMethod") @@ -250,6 +157,10 @@ internal class AddToPortfolioModel @Inject constructor( addToPortfolioManager.onSuccessAdded(result) channel.close() } + fun finishOnAddedTokenClick(result: AddToPortfolioManager.Result) { + addToPortfolioManager.onAddedTokenClick(result) + channel.close() + } fun finishDismissFlow() { addToPortfolioManager.onDismiss() @@ -259,45 +170,85 @@ internal class AddToPortfolioModel @Inject constructor( val tokenMarketParams = paramsSnapshot.token val launchMode = paramsSnapshot.launchMode - val initialData = featureData + val isAccountMode = portfolioSelectorController.isAccountModeSync() + val initialData: AvailableToAddData = addToPortfolioManager.state .filterIsInstance() .map { it.availableToAddData } .first() - if (launchMode is AddToPortfolioManager.LaunchMode.ViaUserPortfolio && + setupPortfolioSelector(initialData, launchMode) + + val shouldShowUserPortfolio = designFeatureToggles.isRedesignEnabled && + launchMode is AddToPortfolioManager.LaunchMode.ViaUserPortfolio && initialData.hasAnyAddedCurrency(tokenMarketParams.id) - ) { + + if (shouldShowUserPortfolio) { // suspend, must prepare UM before navigate to UserPortfolio - userPortfolioStateController.updateAndWaitNotNullState(initialData, tokenMarketParams.id) + userPortfolioStateController.updateAndWaitNotNullState( + allAvailableData = initialData, + rawCurrencyId = tokenMarketParams.id, + ) navigation.replaceAll(AddToPortfolioRoutes.UserPortfolio) callbackDelegate.onContinueFromUserPortfolio.receiveAsFlow().first() } - val selection = selectionResolver.resolve( - availableToAddData = initialData, - orderedNetworks = paramsSnapshot.networks, - selectedWallet = globalSelectedWallet, - tokenParams = tokenMarketParams, - ) ?: run { - finishDismissFlow() - return@channelFlow + + val initialSelection: AddToPortfolioInitialSelectionResolver.InitialSelection? = when (launchMode) { + AddToPortfolioManager.LaunchMode.Preselected -> null + is AddToPortfolioManager.LaunchMode.ViaUserPortfolio, + AddToPortfolioManager.LaunchMode.DirectAdd, + -> if (designFeatureToggles.isRedesignEnabled) { + getInitialSelection(initialData) + } else { + null + } } - val isAccountMode = portfolioSelectorController.isAccountModeSync() - portfolioSelectorController.selectAccount(selection.account.account.accountId) + val firstSelectedPortfolioFlow: Flow = + setupPortfolioFlow(initialData).onEach { selectedPortfolio.emit(it) } + val firstSelectedNetworkFlow: Flow = + setupNetworkFlow(firstSelectedPortfolioFlow).onEach { selectedNetwork.emit(it) } - val firstSelectedPortfolio = SelectedPortfolio( - isAccountMode = isAccountMode, - userWallet = selection.userWallet, - account = selection.account, - isAvailableMorePortfolio = !initialData.isSinglePortfolio, + // main flow that combine all require data + val allRequireForAdd = combine( + flow = firstSelectedNetworkFlow, + flow2 = firstSelectedPortfolioFlow, + transform = { a, b -> a to b }, ) - val firstSelectedNetwork = selection.toSelectedNetwork() ?: run { - finishDismissFlow() - return@channelFlow + + var firstPartOfNavigation: Job? = null + + if (initialSelection != null) { + launch { + portfolioSelectorController.selectAccount(initialSelection.account.account.accountId) + callbackDelegate.onNetworkSelected.send(initialSelection.network) + } + } else { + firstPartOfNavigation = lineNavigationFlowToAddTokenScreen( + isAccountMode = isAccountMode, + data = initialData, + firstSelectedPortfolioFlow = firstSelectedPortfolioFlow, + ).launchIn(this) } - selectedPortfolio.emit(firstSelectedPortfolio) - selectedNetwork.emit(firstSelectedNetwork) + // suspend until all required data is selected + val (firstSelectedNetwork, firstSelectedPortfolio) = allRequireForAdd.first() + // line navigation to AddToken screen is finished; cancel the job if exists + firstPartOfNavigation?.cancel() + + val alreadyAddedToken = getAccountCurrencyStatusUseCase.invokeSync( + userWalletId = firstSelectedPortfolio.userWallet.walletId, + currency = firstSelectedNetwork.cryptoCurrency, + ).getOrNull() + + if (alreadyAddedToken != null) { + val result = AddToPortfolioManager.Result( + wallet = firstSelectedPortfolio.userWallet, + account = firstSelectedPortfolio.account.account, + addedCurrency = alreadyAddedToken.status, + ) + finishOnAddedTokenClick(result) + return@channelFlow + } val selectedNetworkName = firstSelectedNetwork.cryptoCurrency.network.name analyticsEventHandler.send(event = eventBuilder.popupToConfirm(selectedNetworkName)) @@ -317,11 +268,15 @@ internal class AddToPortfolioModel @Inject constructor( callbackDelegate.onChangePortfolioClick.receiveAsFlow() .onEach { middleNavigationJob?.cancel() - middleNavigationJob = changePortfolioNavigationNewFlow( - data = initialData, - orderedNetworks = paramsSnapshot.networks, - tokenParams = tokenMarketParams, - ).launchIn(this) + middleNavigationJob = if (designFeatureToggles.isRedesignEnabled) { + changePortfolioNavigationNewFlow( + data = initialData, + orderedNetworks = paramsSnapshot.networks, + tokenParams = tokenMarketParams, + ) + } else { + changePortfolioNavigationFlow(initialData) + }.launchIn(this) logAccountSelector(isAccountMode) navigation.pushNew(AddToPortfolioRoutes.PortfolioSelector) } @@ -362,6 +317,17 @@ internal class AddToPortfolioModel @Inject constructor( .launchIn(modelScope) } + private suspend fun getInitialSelection( + initialData: AvailableToAddData, + ): AddToPortfolioInitialSelectionResolver.InitialSelection? { + return selectionResolver.resolve( + availableToAddData = initialData, + orderedNetworks = paramsSnapshot.networks, + selectedWallet = globalSelectedWallet, + tokenParams = paramsSnapshot.token, + ) + } + private fun logAccountSelector(isAccountMode: Boolean) { if (isAccountMode) { analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) @@ -523,19 +489,21 @@ internal class AddToPortfolioModel @Inject constructor( return AddToPortfolioRoutes.NetworkSelector(selectedPortfolio = portfolio) } - private fun combineFeatureData() = addToPortfolioManager.state.onEach { state -> - when (state) { - is AddToPortfolioManager.State.Ready -> - portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> - val availableWallet = state.availableToAddData.availableToAddWallets[userWallet.walletId] + private fun setupPortfolioSelector(data: AvailableToAddData, launchMode: AddToPortfolioManager.LaunchMode) { + portfolioSelectorController.isEnabled.value = isEnabled@{ userWallet, accountStatus -> + when (launchMode) { + AddToPortfolioManager.LaunchMode.Preselected -> return@isEnabled true + AddToPortfolioManager.LaunchMode.DirectAdd, + is AddToPortfolioManager.LaunchMode.ViaUserPortfolio, + -> { + val availableWallet = data.availableToAddWallets[userWallet.walletId] ?: return@isEnabled false - val isAvailableAccount = - availableWallet.availableToAddAccounts[accountStatus.account.accountId] - ?.isAvailableToAdd == true + val isAvailableAccount = availableWallet + .availableToAddAccounts[accountStatus.account.accountId] + ?.isAvailableToAdd == true return@isEnabled isAvailableAccount } - AddToPortfolioManager.State.Loading, - -> Unit + } } } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt deleted file mode 100644 index d1e84ecc71..0000000000 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioPreselectedDataModel.kt +++ /dev/null @@ -1,300 +0,0 @@ -package com.tangem.features.commonfeatures.impl.addtoportfolio.model - -import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.replaceAll -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.models.AnalyticsParam -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.ui.UiMessageSender -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.message.ToastMessage -import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.markets.GetTokenMarketCryptoCurrency -import com.tangem.domain.markets.RawMarketToken -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.filterCryptoPortfolio -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher -import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController -import com.tangem.features.commonfeatures.impl.addtoportfolio.AddTokenComponent -import com.tangem.features.commonfeatures.api.addtoportfolio.* -import com.tangem.features.commonfeatures.impl.R -import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.EarnAnalyticsEvent -import com.tangem.features.commonfeatures.impl.addtoportfolio.analytics.PortfolioAnalyticsEvent -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* -import javax.inject.Inject -import kotlin.collections.mapNotNull - -@Suppress("LongParameterList") -internal class AddToPortfolioPreselectedDataModel @Inject constructor( - paramsContainer: ParamsContainer, - portfolioFetcherFactory: PortfolioFetcher.Factory, - override val dispatchers: CoroutineDispatcherProvider, - val portfolioSelectorController: PortfolioSelectorController, - private val callbackDelegate: AddToPortfolioFromEarnCallbackDelegate, - private val messageSender: UiMessageSender, - private val analyticsEventHandler: AnalyticsEventHandler, - private val getTokenMarketCryptoCurrency: GetTokenMarketCryptoCurrency, - private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, -) : Model(), AddTokenComponent.Callbacks by callbackDelegate { - - private val params = paramsContainer.require() - - val portfolioFetcher: PortfolioFetcher = portfolioFetcherFactory.create( - mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = true), - scope = modelScope, - ) - - val navigation = StackNavigation() - var currentStack = listOf(AddToPortfolioRoutes.Empty) - - private val _selectedNetwork = MutableStateFlow(null) - val selectedNetwork: Flow = _selectedNetwork.asStateFlow().filterNotNull() - - private val _selectedPortfolio = MutableStateFlow(null) - val selectedPortfolio: Flow = _selectedPortfolio.asStateFlow().filterNotNull() - val eventBuilder = PortfolioAnalyticsEvent.EventBuilder( - tokenSymbol = params.tokenToAdd.symbol, - source = AnalyticsParam.ScreensSources.Markets.value, - category = AnalyticsParam.ScreensSources.Markets.value, - ) - - init { - navigation.subscribe { currentStack = it.transformer.invoke(currentStack) } - startAddToPortfolioFlow() - } - - @Suppress("LongMethod") - private fun startAddToPortfolioFlow() { - channelFlow { - fun finishSuccessFlow(currency: CryptoCurrency, userWalletId: UserWalletId) { - params.callback.onSuccess(addedToken = currency, walletId = userWalletId) - channel.close() - } - - val data = createAvailableToAddDataForPreselectedNetwork(params.tokenToAdd.network) - ?: return@channelFlow - - val isAccountMode = portfolioSelectorController.isAccountModeSync() - val firstSelectedPortfolio = setupPortfolioFlow(data) - .onEach { _selectedPortfolio.value = it } - - val firstSelectedNetwork = firstSelectedPortfolio - .map { portfolio -> createSelectedNetwork(network = params.tokenToAdd.network, portfolio = portfolio) } - .filterNotNull() - .onEach { _selectedNetwork.value = it } - - val isSinglePortfolio = data.isSinglePortfolio - if (isSinglePortfolio) { - val accountId = data.availableToAddWallets.values.first() - .availableToAddAccounts.values.first() - .account.account.accountId - // force select a portfolio, triggers [selectedPortfolio] - portfolioSelectorController.selectAccount(accountId) - } else { - logAccountSelector(isAccountMode) - navigation.replaceAll(AddToPortfolioRoutes.PortfolioSelector) - } - - // main flow that combine all require data - val allRequireForAdd = combine( - flow = firstSelectedNetwork, - flow2 = firstSelectedPortfolio, - transform = { a, b -> a to b }, - ) - - // suspend until all required data is selected - val (selectedNetworkValue, selectedPortfolioValue) = allRequireForAdd.first() - - val isTokenAlreadyAdded = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = selectedPortfolioValue.userWallet.walletId, - currency = selectedNetworkValue.cryptoCurrency, - ).isSome() - - if (isTokenAlreadyAdded) { - finishSuccessFlow( - currency = selectedNetworkValue.cryptoCurrency, - userWalletId = selectedPortfolioValue.userWallet.walletId, - ) - return@channelFlow - } - - sendAddTokenOpenedAnalytics(selectedNetworkValue.cryptoCurrency) - navigation.replaceAll(AddToPortfolioRoutes.AddToken) - val addedToken = callbackDelegate.onTokenAdded.receiveAsFlow().first() - messageSender.send(ToastMessage(message = resourceReference(R.string.markets_token_added))) - sendSuccessAddedAnalytics(addedToken.currency) - finishSuccessFlow(addedToken.currency, selectedPortfolioValue.userWallet.walletId) - } - .catch { throwable -> - TangemLogger.e("Error", throwable) - params.callback.onDismiss() - } - .launchIn(modelScope) - } - - private fun logAccountSelector(isAccountMode: Boolean) { - if (isAccountMode) { - analyticsEventHandler.send(eventBuilder.popupToChooseAccount()) - } - } - - private fun minimalTokenMarketParams() = with(params.tokenToAdd) { - RawMarketToken( - id = id, - name = name, - symbol = symbol, - ) - } - - /** - * Creates [com.tangem.features.commonfeatures.api.AvailableToAddData] for preselected network when token is already added in all networks. - * This allows user to select wallet/account and do smth after it with selected info. - */ - private suspend fun createAvailableToAddDataForPreselectedNetwork( - preSelectedNetwork: TokenMarketInfo.Network, - ): AvailableToAddData? { - val portfolioData = portfolioFetcher.data.firstOrNull() ?: return null - - val availableToAddInWallets = portfolioData.balances.mapNotNull { (walletId, balance) -> - val wallet = balance.userWallet - val accounts = balance.accountsBalance.accountStatuses.filterCryptoPortfolio() - - val availableToAddAccounts = accounts.mapNotNull { accountStatus -> - val accountIndex = when (accountStatus) { - is AccountStatus.CryptoPortfolio -> accountStatus.account.derivationIndex - } - - val cryptoCurrency = getTokenMarketCryptoCurrency( - userWalletId = walletId, - tokenMarketParams = minimalTokenMarketParams(), - network = preSelectedNetwork, - accountIndex = accountIndex, - ) ?: return@mapNotNull null - - val addedNetworks = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = walletId, - currency = cryptoCurrency, - ).fold( - ifEmpty = { emptySet() }, - ifSome = { setOf(it.status.currency.network) }, - ) - - AvailableToAddAccount( - account = accountStatus, - availableNetworks = setOf(preSelectedNetwork), - addedNetworks = addedNetworks, - ) - }.associateBy { it.account.account.accountId } - - if (availableToAddAccounts.isEmpty()) return@mapNotNull null - - walletId to AvailableToAddWallet( - userWallet = wallet, - accounts = accounts, - availableNetworks = setOf(preSelectedNetwork), - availableToAddAccounts = availableToAddAccounts, - ) - }.toMap() - - if (availableToAddInWallets.isEmpty()) return null - - return AvailableToAddData(availableToAddWallets = availableToAddInWallets) - } - - private suspend fun createCryptoCurrency( - userWallet: UserWallet, - network: TokenMarketInfo.Network, - account: AvailableToAddAccount, - ): CryptoCurrency? { - val accountIndex = when (val accountStatus = account.account) { - is AccountStatus.CryptoPortfolio -> accountStatus.account.derivationIndex - is AccountStatus.Payment -> return null - } - return getTokenMarketCryptoCurrency( - userWalletId = userWallet.walletId, - tokenMarketParams = minimalTokenMarketParams(), - network = network, - accountIndex = accountIndex, - ) - } - - private suspend fun createSelectedNetwork( - network: TokenMarketInfo.Network, - portfolio: SelectedPortfolio, - ): SelectedNetwork? { - val cryptoCurrency = createCryptoCurrency( - userWallet = portfolio.userWallet, - network = network, - account = portfolio.account, - ) ?: return null - - return SelectedNetwork( - cryptoCurrency = cryptoCurrency, - selectedNetwork = network, - isAvailableMoreNetwork = false, - ) - } - - private fun setupPortfolioFlow(data: AvailableToAddData): Flow = combine( - flow = portfolioSelectorController.isAccountMode, - flow2 = portfolioSelectorController.selectedAccount, - transform = { isAccountMode, selectedAccountId -> - selectedAccountId ?: return@combine null - val availableToAddWallets = - data.availableToAddWallets[selectedAccountId.userWalletId] ?: return@combine null - val availableToAddAccount = - availableToAddWallets.availableToAddAccounts[selectedAccountId] ?: return@combine null - if (!isAccountMode) analyticsEventHandler.send(eventBuilder.addToPortfolioWalletChanged()) - SelectedPortfolio( - isAccountMode = isAccountMode, - userWallet = availableToAddWallets.userWallet, - account = availableToAddAccount, - isAvailableMorePortfolio = false, - ) - }, - ) - .filterNotNull() - - private fun sendSuccessAddedAnalytics(cryptoCurrency: CryptoCurrency) { - analyticsEventHandler.send( - EarnAnalyticsEvent.TokenAdded( - tokenSymbol = cryptoCurrency.symbol, - blockchain = cryptoCurrency.network.name, - ), - ) - } - - private fun sendAddTokenOpenedAnalytics(cryptoCurrency: CryptoCurrency) { - analyticsEventHandler.send( - EarnAnalyticsEvent.AddTokenScreenOpened( - tokenSymbol = cryptoCurrency.symbol, - blockchain = cryptoCurrency.network.name, - source = params.analyticsParams.source, - ), - ) - } -} - -@ModelScoped -internal class AddToPortfolioFromEarnCallbackDelegate @Inject constructor() : - AddTokenComponent.Callbacks { - val onTokenAdded = Channel() - - override fun onChangeNetworkClick() = Unit - - override fun onChangePortfolioClick() = Unit - - override fun onTokenAdded(status: CryptoCurrencyStatus) { - onTokenAdded.trySend(status) - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index cb3ad4c829..768445bbd6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -12,7 +12,6 @@ import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent @@ -31,7 +30,6 @@ internal class FeedEntryChildFactory @Inject constructor( private val portfolioComponentFactory: MarketsPortfolioComponent.Factory, private val portfolioBlockComponentFactory: PortfolioBlockComponent.Factory, private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, - private val addToPortfolioPreselectedDataComponent: AddToPortfolioPreselectedDataComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles, private val designFeatureToggles: DesignFeatureToggles, @@ -132,7 +130,7 @@ internal class FeedEntryChildFactory @Inject constructor( DefaultFeedComponent( appComponentContext = appComponentContext, params = FeedParams(feedClickIntents = feedEntryClickIntents), - addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, + addToPortfolioComponentFactory = addToPortfolioComponentFactory, promoBannersBlockComponentFactory = promoBannersBlockComponentFactory, newPromoBannersFeatureToggles = newPromoBannersFeatureToggles, ) @@ -144,7 +142,7 @@ internal class FeedEntryChildFactory @Inject constructor( onBackClick = onBackClicked, onSearchClicked = feedEntryClickIntents::openSearch, ), - addToPortfolioComponentFactory = addToPortfolioPreselectedDataComponent, + addToPortfolioComponentFactory = addToPortfolioComponentFactory, ) } is Child.Search -> DefaultSearchComponent( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index 8ddecd2dc6..bdef4f7ffe 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -33,9 +33,10 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.model.earn.EarnModel +import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.earn.EarnContent import dev.chrisbanes.haze.HazeProgressive @@ -43,7 +44,7 @@ import dev.chrisbanes.haze.HazeProgressive internal class DefaultEarnComponent( appComponentContext: AppComponentContext, private val params: Params, - private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { private val earnModel = getOrCreateModel(params = params) @@ -129,10 +130,14 @@ internal class DefaultEarnComponent( is FeedBottomSheetRoute.AddToPortfolio -> { addToPortfolioComponentFactory.create( context = childByContext(componentContext), - params = AddToPortfolioPreselectedDataComponent.Params( - tokenToAdd = config.tokenToAdd, - callback = earnModel.addToPortfolioCallback, - analyticsParams = AddToPortfolioPreselectedDataComponent.AnalyticsParams(config.source), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = when { + config.source == EarnSource.BEST_OPPORTUNITIES_SOURCE.value -> + earnModel.addBestOpportunitiesPortfolioManager + config.source == EarnSource.MOSTLY_USED_SOURCE.value -> + earnModel.addMostlyUsedPortfolioManager + else -> error("Unknown source: ${config.source}") + }, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index cb39402dc7..21ebee8140 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -11,7 +11,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot -import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext @@ -20,8 +19,7 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent.Params +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.model.feed.FeedComponentModel import com.tangem.features.feed.model.feed.FeedModelClickIntents import com.tangem.features.feed.ui.feed.FeedList @@ -32,7 +30,7 @@ import com.tangem.features.promobanners.api.PromoBannersBlockComponent internal class DefaultFeedComponent( appComponentContext: AppComponentContext, private val params: FeedParams, - private val addToPortfolioComponentFactory: AddToPortfolioPreselectedDataComponent.Factory, + private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles, ) : ComposableModularBottomSheetContentComponent, AppComponentContext by appComponentContext { @@ -102,12 +100,8 @@ internal class DefaultFeedComponent( is FeedBottomSheetRoute.AddToPortfolio -> { addToPortfolioComponentFactory.create( context = childByContext(componentContext), - params = Params( - tokenToAdd = config.tokenToAdd, - callback = feedComponentModel.addToPortfolioCallback, - analyticsParams = AddToPortfolioPreselectedDataComponent.AnalyticsParams( - AnalyticsParam.ScreensSources.Markets.value, - ), + params = AddToPortfolioComponent.Params( + addToPortfolioManager = feedComponentModel.addToPortfolioManager, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt index ecbaa6c87a..fc72c74f86 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/FeedBottomSheetRoute.kt @@ -2,12 +2,10 @@ package com.tangem.features.feed.components.feed import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent import com.tangem.features.feed.components.earn.EarnTypeFilterComponent -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent internal sealed interface FeedBottomSheetRoute { data class AddToPortfolio( - val tokenToAdd: AddToPortfolioPreselectedDataComponent.TokenToAdd, val source: String, ) : FeedBottomSheetRoute diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index 9ecb1ab5f4..d795a1289d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -17,17 +17,18 @@ import com.tangem.domain.earn.model.EarnFilter import com.tangem.domain.earn.model.EarnFilterNetwork import com.tangem.domain.earn.model.EarnFilterType import com.tangem.domain.earn.usecase.* +import com.tangem.domain.markets.RawMarketToken import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnNetworks import com.tangem.domain.models.earn.EarnTokenWithCurrency -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent import com.tangem.features.feed.components.earn.EarnTypeFilterComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent +import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkConverter import com.tangem.features.feed.model.earn.filters.state.EarnFilterNetworkUMConverter import com.tangem.features.feed.model.earn.filters.state.EarnFilterTypeConverter @@ -48,7 +49,7 @@ import javax.inject.Inject @Stable @ModelScoped -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") internal class EarnModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, @@ -62,6 +63,7 @@ internal class EarnModel @Inject constructor( private val appRouter: AppRouter, private val stateController: EarnStateController, private val analyticsEventHandler: AnalyticsEventHandler, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, ) : Model() { private val params = paramsContainer.require() @@ -82,21 +84,24 @@ internal class EarnModel @Inject constructor( dispatchers = dispatchers, ) - val bottomSheetNavigation: SlotNavigation = SlotNavigation() - - val addToPortfolioCallback = object : AddToPortfolioPreselectedDataComponent.Callback { - override fun onDismiss() = bottomSheetNavigation.dismiss() - override fun onSuccess(addedToken: CryptoCurrency, walletId: UserWalletId) { - bottomSheetNavigation.dismiss() - appRouter.push( - AppRoute.CurrencyDetails( - userWalletId = walletId, - currency = addedToken, - ), - ) - } + val addBestOpportunitiesPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = EarnSource.BEST_OPPORTUNITIES_SOURCE.value), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) } + val addMostlyUsedPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = EarnSource.MOSTLY_USED_SOURCE.value), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) + } + + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val state: StateFlow get() = stateController.uiState @@ -107,6 +112,39 @@ internal class EarnModel @Inject constructor( subscribeOnNetworks() subscribeOnBatchFlow() subscribeToMostlyUsed() + + addBestOpportunitiesPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addBestOpportunitiesPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + addBestOpportunitiesPortfolioManager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + + addMostlyUsedPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addMostlyUsedPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + addMostlyUsedPortfolioManager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + } + + private fun openCurrencyDetails(result: AddToPortfolioManager.Result) { + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = result.wallet.walletId, + currency = result.addedCurrency.currency, + ), + ) } private fun subscribeOnBatchFlow() { @@ -260,30 +298,36 @@ internal class EarnModel @Inject constructor( } } - private fun onEarnTokenClick(earnTokenWithCurrency: EarnTokenWithCurrency, source: String) { + private fun onEarnTokenClick(earnTokenWithCurrency: EarnTokenWithCurrency, source: EarnSource) { analyticsEventHandler.send( EarnAnalyticsEvent.OpportunitySelected( tokenSymbol = earnTokenWithCurrency.earnToken.tokenSymbol, blockchain = earnTokenWithCurrency.cryptoCurrency.network.name, - source = source, + source = source.value, ), ) - bottomSheetNavigation.activate( - FeedBottomSheetRoute.AddToPortfolio( - tokenToAdd = AddToPortfolioPreselectedDataComponent.TokenToAdd( - network = TokenMarketInfo.Network( - networkId = earnTokenWithCurrency.earnToken.networkId, - isExchangeable = false, - contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, - decimalCount = earnTokenWithCurrency.earnToken.decimalCount, - ), - id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId), - name = earnTokenWithCurrency.earnToken.tokenName, - symbol = earnTokenWithCurrency.earnToken.tokenSymbol, - ), - source = source, - ), + val token = RawMarketToken( + id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId), + name = earnTokenWithCurrency.earnToken.tokenName, + symbol = earnTokenWithCurrency.earnToken.tokenSymbol, ) + val network = TokenMarketInfo.Network( + networkId = earnTokenWithCurrency.earnToken.networkId, + isExchangeable = false, + contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, + decimalCount = earnTokenWithCurrency.earnToken.decimalCount, + ) + when (source) { + EarnSource.BEST_OPPORTUNITIES_SOURCE -> addBestOpportunitiesPortfolioManager.apply { + setTokenParams(token) + setTokenNetworks(listOf(network)) + } + EarnSource.MOSTLY_USED_SOURCE -> addMostlyUsedPortfolioManager.apply { + setTokenParams(token) + setTokenNetworks(listOf(network)) + } + } + bottomSheetNavigation.activate(FeedBottomSheetRoute.AddToPortfolio(source.value)) } private fun onTypeFilterOptionSelected(type: EarnFilterType) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt index 5369759688..2925791d66 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/analytics/EarnAnalyticsEvent.kt @@ -62,9 +62,10 @@ internal sealed class EarnAnalyticsEvent( ) } -internal const val BEST_OPPORTUNITIES_SOURCE = "Best Opportunity" -internal const val MOSTLY_USED_SOURCE = "Mostly Used" - +internal enum class EarnSource(val value: String) { + BEST_OPPORTUNITIES_SOURCE("Best Opportunity"), + MOSTLY_USED_SOURCE("Mostly Used"), +} internal enum class FilterNetworkAnalytic(val value: String) { ALL_NETWORKS("All Networks"), MY_NETWORKS("My Networks"), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/transformers/UpdateMostlyUsedStateTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/transformers/UpdateMostlyUsedStateTransformer.kt index a3a8317268..e2031ca914 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/transformers/UpdateMostlyUsedStateTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/transformers/UpdateMostlyUsedStateTransformer.kt @@ -3,7 +3,7 @@ package com.tangem.features.feed.model.earn.state.transformers import com.tangem.domain.models.earn.EarnTokenWithCurrency import com.tangem.domain.models.earn.EarnTopToken import com.tangem.features.feed.model.converter.EarnTokenWithCurrencyToListItemUMConverter -import com.tangem.features.feed.model.earn.analytics.MOSTLY_USED_SOURCE +import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.ui.earn.state.EarnListUM import com.tangem.features.feed.ui.earn.state.EarnUM import kotlinx.collections.immutable.toPersistentList @@ -11,12 +11,12 @@ import java.math.BigDecimal internal class UpdateMostlyUsedStateTransformer( private val earnResult: EarnTopToken?, - private val onItemClick: (EarnTokenWithCurrency, source: String) -> Unit, + private val onItemClick: (EarnTokenWithCurrency, source: EarnSource) -> Unit, private val onRetryClick: () -> Unit, ) : EarnUMTransformer { private val converter = EarnTokenWithCurrencyToListItemUMConverter( - onItemClick = { token -> onItemClick(token, MOSTLY_USED_SOURCE) }, + onItemClick = { token -> onItemClick(token, EarnSource.MOSTLY_USED_SOURCE) }, ) override fun transform(prevState: EarnUM): EarnUM { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/statemanager/EarnListBatchFlowManager.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/statemanager/EarnListBatchFlowManager.kt index c241d58af4..e762de7360 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/statemanager/EarnListBatchFlowManager.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/statemanager/EarnListBatchFlowManager.kt @@ -5,7 +5,7 @@ import com.tangem.domain.earn.model.EarnTokensListConfig import com.tangem.domain.earn.usecase.GetEarnTokensBatchFlowUseCase import com.tangem.domain.models.earn.EarnTokenWithCurrency import com.tangem.features.feed.model.converter.EarnTokenWithCurrencyToListItemUMConverter -import com.tangem.features.feed.model.earn.analytics.BEST_OPPORTUNITIES_SOURCE +import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.ui.earn.state.EarnListItemUM import com.tangem.pagination.BatchAction import com.tangem.pagination.PaginationStatus @@ -21,13 +21,13 @@ import kotlinx.coroutines.launch internal class EarnListBatchFlowManager( getEarnTokensBatchFlowUseCase: GetEarnTokensBatchFlowUseCase, private val configProvider: Provider, - private val onItemClick: (EarnTokenWithCurrency, source: String) -> Unit, + private val onItemClick: (EarnTokenWithCurrency, source: EarnSource) -> Unit, private val modelScope: CoroutineScope, private val dispatchers: CoroutineDispatcherProvider, ) { private val actionsFlow = MutableSharedFlow>() private val converter = EarnTokenWithCurrencyToListItemUMConverter( - onItemClick = { onItemClick(it, BEST_OPPORTUNITIES_SOURCE) }, + onItemClick = { onItemClick(it, EarnSource.BEST_OPPORTUNITIES_SOURCE) }, ) private val batchFlow = getEarnTokensBatchFlowUseCase( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index b1d45c9693..7a7c71ccc5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -20,18 +20,14 @@ import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.earn.usecase.FetchTopEarnTokensUseCase import com.tangem.domain.earn.usecase.GetTopEarnTokensUseCase -import com.tangem.domain.markets.GetTopFiveMarketTokenUseCase -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketListConfig -import com.tangem.domain.markets.toSerializableParam +import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnTokenWithCurrency -import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.features.feed.components.feed.DefaultFeedComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioPreselectedDataComponent import com.tangem.features.feed.impl.R import com.tangem.features.feed.model.earn.analytics.EarnAnalyticsEvent import com.tangem.features.feed.model.feed.analytics.FeedAnalyticsEvent @@ -66,6 +62,7 @@ internal class FeedComponentModel @Inject constructor( private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase, private val appRouter: AppRouter, private val designFeatureToggles: DesignFeatureToggles, + addToPortfolioManagerFactory: AddToPortfolioManager.Factory, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, paramsContainer: ParamsContainer, @@ -90,21 +87,16 @@ internal class FeedComponentModel @Inject constructor( dispatchers = dispatchers, ) - val bottomSheetNavigation: SlotNavigation = SlotNavigation() - - val addToPortfolioCallback = object : AddToPortfolioPreselectedDataComponent.Callback { - override fun onDismiss() = bottomSheetNavigation.dismiss() - override fun onSuccess(addedToken: CryptoCurrency, walletId: UserWalletId) { - bottomSheetNavigation.dismiss() - appRouter.push( - AppRoute.CurrencyDetails( - userWalletId = walletId, - currency = addedToken, - ), - ) - } + val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( + scope = modelScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = AnalyticsParam.ScreensSources.Markets.value), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) } + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val state: StateFlow get() = stateController.uiState @@ -118,6 +110,27 @@ internal class FeedComponentModel @Inject constructor( fetchCharts() subscribeOnCurrencyUpdate() subscribeOnDataState() + + addToPortfolioManager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(modelScope) + addToPortfolioManager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + addToPortfolioManager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(modelScope) + } + + private fun openCurrencyDetails(result: AddToPortfolioManager.Result) { + appRouter.push( + AppRoute.CurrencyDetails( + userWalletId = result.wallet.walletId, + currency = result.addedCurrency.currency, + ), + ) } private fun subscribeOnDataState() { @@ -407,22 +420,23 @@ internal class FeedComponentModel @Inject constructor( source = AnalyticsParam.ScreensSources.Markets.value, ), ) - bottomSheetNavigation.activate( - FeedBottomSheetRoute.AddToPortfolio( - tokenToAdd = AddToPortfolioPreselectedDataComponent.TokenToAdd( - network = TokenMarketInfo.Network( - networkId = earnTokenWithCurrency.earnToken.networkId, - isExchangeable = false, - contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, - decimalCount = earnTokenWithCurrency.earnToken.decimalCount, - ), - id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId), - name = earnTokenWithCurrency.earnToken.tokenName, - symbol = earnTokenWithCurrency.earnToken.tokenSymbol, - ), - source = AnalyticsParam.ScreensSources.Markets.value, - ), + val token = RawMarketToken( + id = CryptoCurrency.RawID(earnTokenWithCurrency.earnToken.tokenId), + name = earnTokenWithCurrency.earnToken.tokenName, + symbol = earnTokenWithCurrency.earnToken.tokenSymbol, ) + val network = TokenMarketInfo.Network( + networkId = earnTokenWithCurrency.earnToken.networkId, + isExchangeable = false, + contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, + decimalCount = earnTokenWithCurrency.earnToken.decimalCount, + ) + val route = FeedBottomSheetRoute.AddToPortfolio(AnalyticsParam.ScreensSources.Markets.value) + addToPortfolioManager.apply { + setTokenParams(token) + setTokenNetworks(listOf(network)) + } + bottomSheetNavigation.activate(route) } private fun handleEarnPageOpenClicked() { From e209c70df4493b1e44831aedaba07dc257d6d679 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 16:04:50 +0100 Subject: [PATCH 126/206] Updated on 2026-08-14 --- .../onboarding/v2/addresssync/DefaultAddressSyncComponent.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index 952f61ca37..e5698414d1 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -1,6 +1,7 @@ package com.tangem.features.onboarding.v2.addresssync import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -17,6 +18,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.TangemTheme import com.tangem.features.biometry.AskBiometryComponent import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncIntent import com.tangem.features.onboarding.v2.addresssync.model.AddressSyncModel @@ -86,6 +88,9 @@ internal class DefaultAddressSyncComponent( childContent = { Children( stack = childStack, + modifier = Modifier.background( + color = TangemTheme.colors.background.primary, + ), ) { child -> child.instance.Content(Modifier) } From 3eeacd520e0fac70a56e5a83e25fcfd78cf54a2d Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 28 Apr 2026 19:12:53 +0300 Subject: [PATCH 127/206] Updated on 2026-08-14 --- .../tangem/common/constants/TestConstants.kt | 3 + .../com/tangem/scenarios/SendScenarios.kt | 68 +++++ .../ChooseNetworkBottomSheetPageObject.kt | 8 +- .../tangem/screens/SendSuccessPageObject.kt | 77 +++++ .../tangem/screens/TokenDetailsPageObject.kt | 15 + .../kotlin/com/tangem/tests/FeedbackTest.kt | 2 - .../MainScreenActionButtonsTest.kt | 2 - .../tests/send/sendViaSwap/SendViaSwapTest.kt | 284 +++++++++++++++++- .../tangem/tests/swap/SwapMainScreenTest.kt | 38 +++ .../ui/expressStatus/ExpressStatusItem.kt | 59 ++-- .../crypto/MockDataSignatureVerifier.kt | 6 + .../tangem/datasource/di/SecurityModule.kt | 8 +- .../common/TangemHoldToConfirmButton.kt | 13 +- .../components/inputrow/InputRowBestRate.kt | 5 +- .../transactions/TransactionDoneTitle.kt | 8 +- .../ui/test/TokenDetailsScreenTestTags.kt | 9 + .../test/TransactionSuccessScreenTestTags.kt | 11 + .../success/ui/SendWithSwapSuccessContent.kt | 14 +- 18 files changed, 585 insertions(+), 45 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/SendSuccessPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/swap/SwapMainScreenTest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/crypto/MockDataSignatureVerifier.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/TransactionSuccessScreenTestTags.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt index 4b2d416965..0974d1c8fb 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/constants/TestConstants.kt @@ -32,10 +32,12 @@ object TestConstants { const val DOGECOIN_RECIPIENT_ADDRESS = "DJQR3bdhBKcFGMHX2BkMCkrMFApNWNzr6V" const val DOGECOIN_ADDRESS = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaqz" const val TERRA_RECIPIENT_ADDRESS = "terra148dmp5ccazcwdmrcpvqz5rprnn886kemqen3tj" + const val POLYGON_RECIPIENT_ADDRESS = "0x742d35cc6634c0532925a3b844bc9e7595f2bd18" const val WAIT_UNTIL_TIMEOUT = 20_000L const val WAIT_UNTIL_TIMEOUT_LONG = 30_000L const val WAIT_UNTIL_TIMEOUT_VERY_LONG = 60_000L + const val HOLD_DURATION_MS = 2_000L const val MARKETS_MAIN_NETWORK_SUFFIX = "MAIN" @@ -55,4 +57,5 @@ object TestConstants { "cable meadow add game meat rigid pride" const val SEED_PHRASE_24 = "force visit fresh brown razor target ill scissors figure cave feel genre cargo category " + "bread much nature basic fun iron benefit egg error prosper" + const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash" } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt index 6f2931a598..343231d121 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -1,6 +1,8 @@ package com.tangem.scenarios +import androidx.compose.ui.test.longClick import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG @@ -184,4 +186,70 @@ fun BaseTestCase.openSendConfirmScreenViaNextButton() { step("Assert 'Send' button on 'Send confirm' screen is displayed") { onSendConfirmScreen { sendButton.assertIsDisplayed() } } +} + +fun BaseTestCase.openSendSuccessScreenViaLongClickOnSendButton() { + step("Long click on 'Send' button") { + onSendConfirmScreen { + waitForIdle() + sendButton.assertIsEnabled() + sendButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) } + } + } + step("Assert 'Transaction sent' screen is displayed") { + onSendSuccessScreen { container.assertIsDisplayed() } + } +} + +fun BaseTestCase.checkSendViaSwapSuccessScreen() { + step("Assert 'Transaction sent' title is displayed") { + onSendSuccessScreen { title.assertIsDisplayed() } + } + step("Assert 'Transaction date' is displayed") { + onSendSuccessScreen { transactionDate.assertIsDisplayed() } + } + step("Assert 'Send from' block is displayed") { + onSendSuccessScreen { sendFromBlock.assertIsDisplayed() } + } + step("Assert 'Amount to receive' block is displayed") { + onSendSuccessScreen { sendToAmountBlock.assertIsDisplayed() } + } + step("Assert 'Provider' block is displayed") { + onSendSuccessScreen { providerBlock.assertIsDisplayed() } + } + step("Assert 'Recipient address' block is displayed") { + onSendSuccessScreen { recipientAddressBlock.assertIsDisplayed() } + } + step("Assert 'Network fee' block is displayed") { + onSendSuccessScreen { feeBlock.assertIsDisplayed() } + } + step("Assert 'Explore' button is displayed") { + onSendSuccessScreen { exploreButton.assertIsDisplayed() } + } + step("Assert 'Share' button is displayed") { + onSendSuccessScreen { shareButton.assertIsDisplayed() } + } + step("Assert 'Close' button is displayed") { + onSendSuccessScreen { closeButton.assertIsDisplayed() } + } +} + +fun BaseTestCase.selectTokenToSendViaSwap( + swapTokenName: String, + networkName: String, + networkType: String? = null, +) { + step("Click on 'Send' button") { + onTokenDetailsScreen { sendButton().performClick() } + } + step("Click on 'Swap to another token' button") { + onSendScreen { swapToAnotherTokenButton.performClick() } + } + step("Click on token: '$swapTokenName'") { + onSendViaSwapScreen { tokenItem(swapTokenName).performClick() } + } + val networkLabel = if (networkType.isNullOrBlank()) networkName else "$networkName $networkType" + step("Click on '$networkLabel' network") { + onChooseNetworkBottomSheet { networkItem(networkName, networkType).performClick() } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ChooseNetworkBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ChooseNetworkBottomSheetPageObject.kt index 1c179d6575..4a200f8033 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/ChooseNetworkBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/ChooseNetworkBottomSheetPageObject.kt @@ -20,10 +20,12 @@ class ChooseNetworkBottomSheetPageObject(semanticsProvider: SemanticsNodeInterac useUnmergedTree = true } - fun networkItem(title: String, subtitle: String): KNode = child { + fun networkItem(name: String, type: String? = null): KNode = child { hasTestTag(ChooseNetworkBottomSheetTestTags.NETWORK_ITEM) - hasAnyDescendant(withText(title)) - hasAnyDescendant(withText(subtitle)) + hasAnyDescendant(withText(name)) + if (!type.isNullOrBlank()) { + hasAnyDescendant(withText(type)) + } useUnmergedTree = true } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendSuccessPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendSuccessPageObject.kt new file mode 100644 index 0000000000..c593a2566d --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendSuccessPageObject.kt @@ -0,0 +1,77 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.common.R +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.TransactionSuccessScreenTestTags +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasText as withText + +class SendSuccessPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val container: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.CONTAINER) + } + + val title: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.TITLE) + useUnmergedTree = true + } + + val transactionDate: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.TRANSACTION_DATE) + useUnmergedTree = true + } + + val sendFromBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.AMOUNT_BLOCK) + hasAnyDescendant(withText(getResourceString(R.string.send_from_title))) + useUnmergedTree = true + } + + val sendToAmountBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.AMOUNT_BLOCK) + hasAnyDescendant( + withText(getResourceString(R.string.send_with_swap_recipient_amount_success_title)) + ) + useUnmergedTree = true + } + + val providerBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.PROVIDER_BLOCK) + useUnmergedTree = true + } + + val recipientAddressBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.RECIPIENT_BLOCK) + useUnmergedTree = true + } + + val feeBlock: KNode = child { + hasTestTag(TransactionSuccessScreenTestTags.FEE_BLOCK) + useUnmergedTree = true + } + + val exploreButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_explore)) + } + + val closeButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_close)) + } + + val shareButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasText(getResourceString(R.string.common_share)) + } +} + +internal fun BaseTestCase.onSendSuccessScreen(function: SendSuccessPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index dff98cfcee..4996f7db80 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -16,6 +16,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString +import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : @@ -192,6 +193,20 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide hasText(getResourceString(R.string.common_buy_currency, feeCurrencySymbol)) useUnmergedTree = true } + + fun expressStatusItem(title: String): KNode = child { + hasTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM) + hasAnyDescendant( + withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE) and withText(title) + ) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_AMOUNT)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT)) + hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON)) + useUnmergedTree = true + } } internal fun BaseTestCase.onTokenDetailsScreen(function: TokenDetailsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index 528a6535fb..6f103dd4b6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -32,7 +32,6 @@ import com.tangem.tap.domain.sdk.mocks.MockProvider import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName -import org.junit.Ignore import org.junit.Test import javax.inject.Inject @@ -73,7 +72,6 @@ class FeedbackTest : BaseTestCase() { } } - @Ignore("TODO: [REDACTED_JIRA]") @AllureId("893") @DisplayName("Send feedback: failed transaction") @Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index 2cb635f684..1f795cadce 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -21,7 +21,6 @@ import com.tangem.tap.domain.sdk.mocks.content.TwinsMockContent import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName -import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -462,7 +461,6 @@ class MainScreenActionButtonsTest : BaseTestCase() { } } - @Ignore("TODO: [REDACTED_JIRA]") @AllureId("4396") @DisplayName("Action buttons (main screen): click on buttons without data") @Test diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt index 22f4645cf3..1280e102da 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt @@ -2,14 +2,18 @@ package com.tangem.tests.send.sendViaSwap import com.tangem.common.BaseTestCase import com.tangem.common.R -import com.tangem.common.extensions.extractText import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.POLYGON_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.SOLANA_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.extractText import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState -import com.tangem.scenarios.openSendConfirmScreenViaNextButton -import com.tangem.scenarios.openSendScreen +import com.tangem.scenarios.* import com.tangem.screens.* import dagger.hilt.android.testing.HiltAndroidTest import io.github.kakaocup.kakao.common.utilities.getResourceString @@ -369,4 +373,278 @@ class SendViaSwapTest : BaseTestCase() { } } } + + @AllureId("3967") + @DisplayName("Send via Swap: full successful send via swap flow") + @Test + fun sendViaSwapSuccessfulFlowTest() { + val tokenName = "Bitcoin" + val swapTokenName = "Ethereum" + val main = "MAIN" + val inputAmount = "0.001" + val providerName = "SimpleSwap" + val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName) + val bitcoinBalanceScenarioName = "bitcoin_utxo" + val bitcoinBalanceScenarioState = "BalanceHotWalletSvS" + val assetsScenarioName = "express_api_assets" + val assetsScenarioState = "BitcoinExchangeEnabled" + val hotWalletScenarioState = "HotWalletSvS" + val providersScenarioName = "networks_providers" + val providersScenarioState = "HotWalletSvS" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(bitcoinBalanceScenarioName) + resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(providersScenarioName) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$tokenName'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = tokenName) + } + step("Set WireMock scenario: '$bitcoinBalanceScenarioName' to state: '$bitcoinBalanceScenarioState'") { + setWireMockScenarioState(scenarioName = bitcoinBalanceScenarioName, state = bitcoinBalanceScenarioState) + } + step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") { + setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState) + } + step("Set WireMock scenario: '$providersScenarioName' to state: '$providersScenarioState'") { + setWireMockScenarioState(scenarioName = providersScenarioName, state = providersScenarioState) + } + + step("Open 'Main Screen' with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Select token to Send via Swap") { + selectTokenToSendViaSwap(swapTokenName = swapTokenName, networkName = swapTokenName, networkType = main) + } + step("Type '$inputAmount' in text field") { + onSendScreen { amountInputTextField.performTextInput(inputAmount) } + } + step("Click on 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendScreen { + nextButton.assertIsEnabled() + nextButton.performClick() + } + } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) } + } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } + } + step("Assert 'Best rate' badge is displayed") { + onSendConfirmScreen { bestRateBadge.assertIsDisplayed() } + } + step("Open 'Send via swap success' screen") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + step("Check 'Send via swap' screen") { + checkSendViaSwapSuccessScreen() + } + step("Click on 'Explore' button") { + onSendSuccessScreen { exploreButton.performClick() } + } + step("Assert Chrome Browser is opened") { + ThirdPartyAppPageObject { assertChromeIsOpened() } + } + step("Press 'Back' button to close 'Chrome' browser") { + device.uiDevice.pressBack() + } + step("Click on 'Close' button") { + onSendSuccessScreen { closeButton.performClick() } + } + step("Assert 'Express status' item is displayed with title: '$expressStatusItemTitle'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() } + } + } + } + } + + @AllureId("4017") + @DisplayName("Send via Swap: send same token in different network") + @Test + fun sendSameTokenInDifferentNetworkTest() { + val tokenName = "Tether" + val swapTokenName = "Tether" + val networkName = "Polygon" + val inputAmount = "0.001" + val ethCallScenarioName = "eth_call_api" + val ethCallScenarioState = "Started" + val hotWalletScenarioState = "USDTHotWalletSvS" + val ethNetworkBalanceScenarioName = "eth_network_balance" + val ethNetworkBalanceScenarioState = "Started" + val providerName = "Changelly" + val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(ethCallScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(ethNetworkBalanceScenarioName) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$ethCallScenarioName' to state: '$ethCallScenarioState'") { + setWireMockScenarioState(scenarioName = ethCallScenarioName, state = ethCallScenarioState) + } + step("Set WireMock scenario: '$ethNetworkBalanceScenarioName' to state: '$ethNetworkBalanceScenarioState'") { + setWireMockScenarioState(scenarioName = ethNetworkBalanceScenarioName, state = ethNetworkBalanceScenarioState) + } + + step("Open 'Main Screen' with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Select token to Send via Swap") { + selectTokenToSendViaSwap(swapTokenName = swapTokenName, networkName = networkName) + } + step("Type '$inputAmount' in text field") { + onSendScreen { amountInputTextField.performTextInput(inputAmount) } + } + step("Click on 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendScreen { + nextButton.assertIsEnabled() + nextButton.performClick() + } + } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(POLYGON_RECIPIENT_ADDRESS) } + } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } + } + step("Assert 'Best rate' badge is displayed") { + onSendConfirmScreen { bestRateBadge.assertIsDisplayed() } + } + step("Open 'Send via swap success' screen") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + step("Check 'Send via swap' screen") { + checkSendViaSwapSuccessScreen() + } + step("Click on 'Close' button") { + onSendSuccessScreen { closeButton.performClick() } + } + step("Assert 'Express status' item is displayed with title: '$expressStatusItemTitle'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() } + } + } + } + } + + @AllureId("4545") + @DisplayName("Send via Swap: token with tag/memo send via swap flow") + @Test + fun sendViaSwapTokenWithTagTest() { + val tokenName = "XRP Ledger" + val swapTokenName = "Ethereum" + val main = "MAIN" + val inputAmount = "0.001" + val providerName = "Changelly" + val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName) + val hotWalletScenarioState = "XRPHotWalletSvS" + val xrpExchangeQuoteScenarioName = "xrp_exchange_quote" + val xrpExchangeDataScenarioName = "xrp_exchange_data" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(xrpExchangeQuoteScenarioName) + resetWireMockScenarioState(xrpExchangeDataScenarioName) + } + ).run { + + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$xrpExchangeQuoteScenarioName' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = xrpExchangeQuoteScenarioName, state = hotWalletScenarioState) + } + step("Set WireMock scenario: '$xrpExchangeDataScenarioName' to state: '$hotWalletScenarioState'") { + setWireMockScenarioState(scenarioName = xrpExchangeDataScenarioName, state = hotWalletScenarioState) + } + + step("Open 'Main Screen' with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Select token to Send via Swap") { + selectTokenToSendViaSwap(swapTokenName = swapTokenName, networkName = swapTokenName, networkType = main) + } + step("Type '$inputAmount' in text field") { + onSendScreen { amountInputTextField.performTextInput(inputAmount) } + } + step("Click on 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendScreen { + nextButton.assertIsEnabled() + nextButton.performClick() + } + } + } + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(ETHEREUM_RECIPIENT_ADDRESS) } + } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } + } + step("Open 'Send via swap success' screen") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + step("Check 'Send via swap' screen") { + checkSendViaSwapSuccessScreen() + } + step("Click on 'Close' button") { + onSendSuccessScreen { closeButton.performClick() } + } + step("Assert 'Express status' item is displayed with title: '$expressStatusItemTitle'") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() } + } + } + } + } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapMainScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapMainScreenTest.kt new file mode 100644 index 0000000000..678973c9d2 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapMainScreenTest.kt @@ -0,0 +1,38 @@ +package com.tangem.tests.swap + +import com.tangem.common.BaseTestCase +import com.tangem.common.annotations.ApiEnv +import com.tangem.common.annotations.ApiEnvConfig +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.domain.models.scan.ProductType +import com.tangem.scenarios.openMainScreen +import com.tangem.screens.onMainScreen +import dagger.hilt.android.testing.HiltAndroidTest +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class SwapMainScreenTest : BaseTestCase() { + + @ApiEnv( + ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD), + ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD) + ) + @AllureId("574") + @DisplayName("Swap: 'Swap' button is not displayed for single currency card") + @Test + fun singleTokenNoteCardScanTest() { + val cardType: ProductType = ProductType.Note + + setupHooks().run { + step("Open 'Main Screen' on '${cardType.name}' card") { + openMainScreen(cardType) + } + step("Assert 'Swap' button is not displayed") { + onMainScreen { swapButton.assertIsNotDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt index 25b2c3389d..e44c2a673b 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -28,6 +29,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TokenDetailsScreenTestTags @Suppress("DestructuringDeclarationWithTooManyEntries", "LongMethod", "LongParameterList") @Composable @@ -50,7 +52,8 @@ internal fun ExpressStatusItem( .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.primary) .clickable { onClick() } - .padding(TangemTheme.dimens.spacing12), + .padding(TangemTheme.dimens.spacing12) + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM), ) { val (titleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) = createRefs() val padding6 = TangemTheme.dimens.spacing6 @@ -59,10 +62,12 @@ internal fun ExpressStatusItem( text = title.resolveReference(), style = TangemTheme.typography.subtitle2, color = TangemTheme.colors.text.tertiary, - modifier = Modifier.constrainAs(titleRef) { - start.linkTo(parent.start) - top.linkTo(parent.top) - }, + modifier = Modifier + .constrainAs(titleRef) { + start.linkTo(parent.start) + top.linkTo(parent.top) + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE), ) CurrencyIcon( state = fromTokenIconState, @@ -73,20 +78,23 @@ internal fun ExpressStatusItem( start.linkTo(parent.start) top.linkTo(titleRef.bottom, padding6) bottom.linkTo(parent.bottom) - }, + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON), ) EllipsisText( text = fromAmount.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.primary1, ellipsis = TextEllipsis.OffsetEnd(fromSymbol.length), - modifier = Modifier.constrainAs(fromRef) { - start.linkTo(fromIconRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) - end.linkTo(swapIconRef.start) - bottom.linkTo(parent.bottom) - width = Dimension.fillToConstraints.atMostWrapContent - }, + modifier = Modifier + .constrainAs(fromRef) { + start.linkTo(fromIconRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(swapIconRef.start) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints.atMostWrapContent + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_AMOUNT), ) Icon( painter = painterResource(id = R.drawable.ic_forward_24), @@ -99,7 +107,8 @@ internal fun ExpressStatusItem( top.linkTo(titleRef.bottom, padding6) end.linkTo(toIconRef.start) bottom.linkTo(parent.bottom) - }, + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON), ) CurrencyIcon( state = toTokenIconState, @@ -111,20 +120,23 @@ internal fun ExpressStatusItem( top.linkTo(titleRef.bottom, padding6) end.linkTo(toRef.start) bottom.linkTo(parent.bottom) - }, + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON), ) EllipsisText( text = toAmount.resolveReference(), style = TangemTheme.typography.body2, color = TangemTheme.colors.text.primary1, ellipsis = TextEllipsis.OffsetEnd(toSymbol.length), - modifier = Modifier.constrainAs(toRef) { - start.linkTo(toIconRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) - end.linkTo(infoIconRef.start, padding6, padding6) - bottom.linkTo(parent.bottom) - width = Dimension.fillToConstraints.atLeastWrapContent - }, + modifier = Modifier + .constrainAs(toRef) { + start.linkTo(toIconRef.end, padding6) + top.linkTo(titleRef.bottom, padding6) + end.linkTo(infoIconRef.start, padding6, padding6) + bottom.linkTo(parent.bottom) + width = Dimension.fillToConstraints.atLeastWrapContent + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT), ) Icon( painter = painterResource(id = infoIconRes ?: R.drawable.ic_alert_triangle_20), @@ -153,7 +165,8 @@ internal fun ExpressStatusItem( end.linkTo(parent.end) top.linkTo(parent.top) bottom.linkTo(parent.bottom) - }, + } + .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON), ) } } diff --git a/core/datasource/src/main/java/com/tangem/datasource/crypto/MockDataSignatureVerifier.kt b/core/datasource/src/main/java/com/tangem/datasource/crypto/MockDataSignatureVerifier.kt new file mode 100644 index 0000000000..79f0b6cb63 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/crypto/MockDataSignatureVerifier.kt @@ -0,0 +1,6 @@ +package com.tangem.datasource.crypto + +internal class MockDataSignatureVerifier : DataSignatureVerifier { + + override fun verifySignature(signature: String, data: String): Boolean = true +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt index c04614c1f7..49ba336022 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/SecurityModule.kt @@ -1,7 +1,9 @@ package com.tangem.datasource.di +import com.tangem.datasource.BuildConfig import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.crypto.DataSignatureVerifier +import com.tangem.datasource.crypto.MockDataSignatureVerifier import com.tangem.datasource.crypto.Sha256SignatureVerifier import com.tangem.datasource.local.config.environment.EnvironmentConfig import dagger.Module @@ -20,6 +22,10 @@ internal object SecurityModule { environmentConfig: EnvironmentConfig, apiConfigsManager: ApiConfigsManager, ): DataSignatureVerifier { - return Sha256SignatureVerifier(environmentConfig, apiConfigsManager) + return if (BuildConfig.MOCK_DATA_SOURCE) { + MockDataSignatureVerifier() + } else { + Sha256SignatureVerifier(environmentConfig, apiConfigsManager) + } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemHoldToConfirmButton.kt b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemHoldToConfirmButton.kt index 3ee514559a..9e5c8386c2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemHoldToConfirmButton.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/buttons/common/TangemHoldToConfirmButton.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -58,6 +59,7 @@ import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.BaseButtonTestTags import kotlin.coroutines.coroutineContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -145,6 +147,7 @@ internal fun TangemHoldToConfirmButton( Surface( modifier = modifier .heightIn(min = buttonHeight) + .testTag(BaseButtonTestTags.BUTTON) .graphicsLayer { scaleX = state.scaleProgress.value scaleY = state.scaleProgress.value @@ -442,10 +445,12 @@ private fun HoldToConfirmButtonContent( ) } else { Text( - modifier = Modifier.graphicsLayer { - translationX = state.shakeOffset.value - alpha = state.textAlpha.value - }, + modifier = Modifier + .testTag(BaseButtonTestTags.TEXT) + .graphicsLayer { + translationX = state.shakeOffset.value + alpha = state.textAlpha.value + }, text = if (state.isHintVisible) textConfig.hintText else textConfig.text, style = textConfig.style, color = color.contentColor, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt index 1dea3f423a..fb2f6005b4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/inputrow/InputRowBestRate.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -30,6 +31,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TransactionSuccessScreenTestTags /** * [Input Row Best Rate](https://www.figma.com/file/14ISV23YB1yVW1uNVwqrKv/Android?type=design&node-id=2100-889&mode=dev) @@ -61,7 +63,8 @@ fun InputRowBestRate( Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(TangemTheme.dimens.spacing12), + .padding(TangemTheme.dimens.spacing12) + .testTag(TransactionSuccessScreenTestTags.PROVIDER_BLOCK), ) { InputRowAsyncImage(imageUrl = imageUrl, modifier = Modifier.size(TangemTheme.dimens.spacing40)) Column( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt index 0a5cc124d1..584f323bf4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionDoneTitle.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -21,6 +22,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TransactionSuccessScreenTestTags /** * Common transaction done screen title @@ -46,7 +48,8 @@ fun TransactionDoneTitle(title: TextReference, subtitle: TextReference, modifier style = TangemTheme.typography.h3, color = TangemTheme.colors.text.primary1, modifier = Modifier - .padding(top = TangemTheme.dimens.spacing16), + .padding(top = TangemTheme.dimens.spacing16) + .testTag(TransactionSuccessScreenTestTags.TITLE), ) Text( text = subtitle.resolveReference(), @@ -54,7 +57,8 @@ fun TransactionDoneTitle(title: TextReference, subtitle: TextReference, modifier color = TangemTheme.colors.text.tertiary, textAlign = TextAlign.Center, modifier = Modifier - .padding(top = TangemTheme.dimens.spacing4), + .padding(top = TangemTheme.dimens.spacing4) + .testTag(TransactionSuccessScreenTestTags.TRANSACTION_DATE), ) } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TokenDetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TokenDetailsScreenTestTags.kt index f9e953824d..9079c600f0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/TokenDetailsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TokenDetailsScreenTestTags.kt @@ -15,4 +15,13 @@ object TokenDetailsScreenTestTags { const val STAKING_TOKEN_AMOUNT = "TOKEN_DETAILS_SCREEN_STAKING_TOKEN_AMOUNT" const val STAKING_REWARD_VALUE = "TOKEN_DETAILS_SCREEN_STAKING_REWARD_VALUE" const val STAKING_CHEVRON_ICON = "TOKEN_DETAILS_SCREEN_STAKING_CHEVRON_ICON" + + const val EXPRESS_STATUS_ITEM = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM" + const val EXPRESS_STATUS_ITEM_TITLE = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_TITLE" + const val EXPRESS_STATUS_ITEM_FROM_ICON = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_FROM_ICON" + const val EXPRESS_STATUS_ITEM_FROM_AMOUNT = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_FROM_AMOUNT" + const val EXPRESS_STATUS_ITEM_SWAP_ICON = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_SWAP_ICON" + const val EXPRESS_STATUS_ITEM_TO_ICON = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_TO_ICON" + const val EXPRESS_STATUS_ITEM_TO_AMOUNT = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_TO_AMOUNT" + const val EXPRESS_STATUS_ITEM_CHEVRON_ICON = "TOKEN_DETAILS_SCREEN_EXPRESS_STATUS_ITEM_CHEVRON_ICON" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TransactionSuccessScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionSuccessScreenTestTags.kt new file mode 100644 index 0000000000..2f2ca91b51 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionSuccessScreenTestTags.kt @@ -0,0 +1,11 @@ +package com.tangem.core.ui.test + +object TransactionSuccessScreenTestTags { + const val CONTAINER = "TRANSACTION_SUCCESS_SCREEN_CONTAINER" + const val TITLE = "TRANSACTION_SUCCESS_SCREEN_TITLE" + const val TRANSACTION_DATE = "TRANSACTION_SUCCESS_SCREEN_DATE" + const val AMOUNT_BLOCK = "TRANSACTION_SUCCESS_SCREEN_AMOUNT_BLOCK" + const val FEE_BLOCK = "TRANSACTION_SUCCESS_SCREEN_FEE_BLOCK" + const val PROVIDER_BLOCK = "TRANSACTION_SUCCESS_SCREEN_PROVIDER_BLOCK" + const val RECIPIENT_BLOCK = "TRANSACTION_SUCCESS_SCREEN_RECIPIENT_BLOCK" +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt index 834289a971..8acb32e4a7 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.blockchain.common.transaction.Fee @@ -36,6 +37,7 @@ import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TransactionSuccessScreenTestTags import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.express.models.ExpressProvider @@ -68,7 +70,8 @@ internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) { Box( modifier = Modifier .weight(1f) - .background(TangemTheme.colors.background.tertiary), + .background(TangemTheme.colors.background.tertiary) + .testTag(TransactionSuccessScreenTestTags.CONTAINER), ) { SuccessContent( sendWithSwapUM = sendWithSwapUM, @@ -162,7 +165,8 @@ private fun AmountBlock( modifier = modifier .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action) - .padding(12.dp), + .padding(12.dp) + .testTag(TransactionSuccessScreenTestTags.AMOUNT_BLOCK), ) { AccountTitle(accountTitleUM) Row( @@ -212,7 +216,8 @@ private fun FeeBlock(feeSelectorUM: FeeSelectorUM.Content) { .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action) - .padding(TangemTheme.dimens.spacing12), + .padding(TangemTheme.dimens.spacing12) + .testTag(TransactionSuccessScreenTestTags.FEE_BLOCK), ) { Text( text = stringResourceSafe(R.string.common_network_fee_title), @@ -260,7 +265,8 @@ private fun DestinationBlock( .fillMaxWidth() .clip(TangemTheme.shapes.roundedCornersXMedium) .background(TangemTheme.colors.background.action) - .padding(12.dp), + .padding(12.dp) + .testTag(TransactionSuccessScreenTestTags.RECIPIENT_BLOCK), ) { Text( text = stringResourceSafe(R.string.send_recipient), From 8511623421c81dc4e8c1dae13d306b96fc1a7755 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Apr 2026 17:59:40 +0400 Subject: [PATCH 128/206] Updated on 2026-08-14 --- .../ui/expressStatus/ExpressStatusItem.kt | 38 ++- .../ui/expressStatus/ExpressStatusItems.kt | 9 +- .../ui/expressStatus/OnrampStatusMapper.kt | 38 +++ .../state/ExpressStatusSubtitleBuilder.kt | 52 ++++ .../state/ExpressTransactionStateUM.kt | 8 +- .../state/ExpressStatusSubtitleBuilderTest.kt | 117 +++++++++ core/ui/build.gradle.kts | 5 + .../com/tangem/core/ui/utils/DateUtils.kt | 15 +- .../core/ui/utils/FormattedDateMapper.kt | 55 ++++ .../core/ui/utils/DateTimeFormattersTest.kt | 17 ++ .../com/tangem/core/ui/utils/DateUtilsTest.kt | 19 ++ .../core/ui/utils/FormattedDateMapperTest.kt | 234 ++++++++++++++++++ .../ShortArticleToArticleConfigUMConverter.kt | 2 +- .../details/converter/NewsDetailsConverter.kt | 38 +-- .../feed/ui/utils/CreatedTimeFormatter.kt | 43 ---- ...nDetailsOnrampTransactionStateConverter.kt | 19 +- ...enDetailsSwapTransactionsStateConverter.kt | 24 ++ .../ExpressStatusBottomSheetStateProvider.kt | 2 + .../SingleWalletOnrampTransactionConverter.kt | 20 +- 19 files changed, 633 insertions(+), 122 deletions(-) create mode 100644 common/ui/src/main/java/com/tangem/common/ui/expressStatus/OnrampStatusMapper.kt create mode 100644 common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilder.kt create mode 100644 common/ui/src/test/kotlin/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilderTest.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/utils/FormattedDateMapper.kt create mode 100644 core/ui/src/test/kotlin/com/tangem/core/ui/utils/FormattedDateMapperTest.kt delete mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt index e44c2a673b..88a40df4bd 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItem.kt @@ -20,11 +20,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.constraintlayout.compose.* import com.tangem.common.ui.R +import com.tangem.common.ui.expressStatus.state.buildExpressStatusSubtitle import com.tangem.core.ui.components.atoms.text.EllipsisText import com.tangem.core.ui.components.atoms.text.TextEllipsis import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.isNullOrEmpty import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme @@ -43,6 +45,7 @@ internal fun ExpressStatusItem( onClick: () -> Unit, modifier: Modifier = Modifier, toAmount: TextReference = TextReference.EMPTY, + subtitle: TextReference = TextReference.EMPTY, @DrawableRes infoIconRes: Int? = null, infoIconTint: Color? = null, ) { @@ -55,7 +58,8 @@ internal fun ExpressStatusItem( .padding(TangemTheme.dimens.spacing12) .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM), ) { - val (titleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) = createRefs() + val (titleRef, subtitleRef, iconRef, infoIconRef, swapIconRef, fromRef, toRef, fromIconRef, toIconRef) = + createRefs() val padding6 = TangemTheme.dimens.spacing6 Text( @@ -66,9 +70,25 @@ internal fun ExpressStatusItem( .constrainAs(titleRef) { start.linkTo(parent.start) top.linkTo(parent.top) + end.linkTo(infoIconRef.start, padding6, padding6) + width = Dimension.preferredWrapContent + horizontalBias = 0f } .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TITLE), ) + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + modifier = Modifier.constrainAs(subtitleRef) { + start.linkTo(parent.start) + top.linkTo(titleRef.bottom) + end.linkTo(infoIconRef.start, padding6, padding6) + width = Dimension.preferredWrapContent + horizontalBias = 0f + visibility = if (subtitle.isNullOrEmpty()) Visibility.Gone else Visibility.Visible + }, + ) CurrencyIcon( state = fromTokenIconState, shouldDisplayNetwork = false, @@ -76,7 +96,7 @@ internal fun ExpressStatusItem( .size(TangemTheme.dimens.size20) .constrainAs(fromIconRef) { start.linkTo(parent.start) - top.linkTo(titleRef.bottom, padding6) + top.linkTo(subtitleRef.bottom, padding6) bottom.linkTo(parent.bottom) } .testTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_FROM_ICON), @@ -89,7 +109,7 @@ internal fun ExpressStatusItem( modifier = Modifier .constrainAs(fromRef) { start.linkTo(fromIconRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) + top.linkTo(subtitleRef.bottom, padding6) end.linkTo(swapIconRef.start) bottom.linkTo(parent.bottom) width = Dimension.fillToConstraints.atMostWrapContent @@ -104,7 +124,7 @@ internal fun ExpressStatusItem( .size(TangemTheme.dimens.size12) .constrainAs(swapIconRef) { start.linkTo(fromRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) + top.linkTo(subtitleRef.bottom, padding6) end.linkTo(toIconRef.start) bottom.linkTo(parent.bottom) } @@ -117,7 +137,7 @@ internal fun ExpressStatusItem( .size(TangemTheme.dimens.size20) .constrainAs(toIconRef) { start.linkTo(swapIconRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) + top.linkTo(subtitleRef.bottom, padding6) end.linkTo(toRef.start) bottom.linkTo(parent.bottom) } @@ -131,7 +151,7 @@ internal fun ExpressStatusItem( modifier = Modifier .constrainAs(toRef) { start.linkTo(toIconRef.end, padding6) - top.linkTo(titleRef.bottom, padding6) + top.linkTo(subtitleRef.bottom, padding6) end.linkTo(infoIconRef.start, padding6, padding6) bottom.linkTo(parent.bottom) width = Dimension.fillToConstraints.atLeastWrapContent @@ -180,13 +200,17 @@ private fun ExpressStatusItemPreview( ) { TangemThemePreview { ExpressStatusItem( - title = stringReference("ChangeNow"), + title = stringReference("Exchange by ChangeHero"), fromTokenIconState = CurrencyIconState.Loading, toTokenIconState = CurrencyIconState.Loading, fromAmount = stringReference(amount), fromSymbol = "USDT", toAmount = stringReference(amount), toSymbol = "USDT", + subtitle = buildExpressStatusSubtitle( + activeStatus = stringReference("Confirming"), + date = stringReference("59 min ago"), + ), onClick = {}, infoIconRes = null, infoIconTint = null, diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt index 1424805a32..49d77a4633 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/ExpressStatusItems.kt @@ -14,10 +14,10 @@ fun LazyListScope.expressTransactionsItems( ) { items( count = expressTxs.size, - key = { expressTxs[it].info.txId }, - contentType = { expressTxs[it]::class.java }, - ) { - val itemInfo = expressTxs[it].info + key = { index -> expressTxs[index].info.txId }, + contentType = { index -> expressTxs[index]::class.java }, + ) { index -> + val itemInfo = expressTxs[index].info val (iconRes, tint) = when (itemInfo.iconState) { ExpressTransactionStateIconUM.Warning -> { R.drawable.ic_alert_triangle_20 to TangemTheme.colors.icon.attention @@ -36,6 +36,7 @@ fun LazyListScope.expressTransactionsItems( fromSymbol = itemInfo.fromAmountSymbol, toAmount = itemInfo.toAmount, toSymbol = itemInfo.toAmountSymbol, + subtitle = itemInfo.subtitle, onClick = itemInfo.onClick, infoIconRes = iconRes, infoIconTint = tint, diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/OnrampStatusMapper.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/OnrampStatusMapper.kt new file mode 100644 index 0000000000..a1e1b27b08 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/OnrampStatusMapper.kt @@ -0,0 +1,38 @@ +package com.tangem.common.ui.expressStatus + +import com.tangem.common.ui.R +import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateIconUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.onramp.model.OnrampStatus + +fun OnrampStatus.Status.toActiveStatusText(currencyName: String): TextReference = when (this) { + OnrampStatus.Status.Created, + OnrampStatus.Status.WaitingForPayment, + -> resourceReference(R.string.express_exchange_status_receiving_active) + OnrampStatus.Status.PaymentProcessing -> resourceReference(R.string.express_exchange_status_confirming_active) + OnrampStatus.Status.Verifying -> resourceReference(R.string.express_exchange_status_verifying) + OnrampStatus.Status.Paid -> resourceReference(R.string.express_status_buying_active, wrappedList(currencyName)) + OnrampStatus.Status.Sending -> resourceReference( + R.string.express_exchange_status_sending_active, + wrappedList(currencyName), + ) + OnrampStatus.Status.Finished -> resourceReference(R.string.express_status_bought, wrappedList(currencyName)) + OnrampStatus.Status.RefundInProgress -> resourceReference(R.string.express_exchange_status_refunding) + OnrampStatus.Status.Refunded -> resourceReference(R.string.express_exchange_status_refunded) + OnrampStatus.Status.Paused -> resourceReference(R.string.express_exchange_status_paused) + OnrampStatus.Status.Expired, + OnrampStatus.Status.Failed, + -> resourceReference(R.string.express_exchange_status_failed) +} + +fun OnrampStatus.Status.toIconState(): ExpressTransactionStateIconUM = when (this) { + OnrampStatus.Status.Verifying, + OnrampStatus.Status.RefundInProgress, + -> ExpressTransactionStateIconUM.Warning + OnrampStatus.Status.Refunded, + OnrampStatus.Status.Failed, + -> ExpressTransactionStateIconUM.Error + else -> ExpressTransactionStateIconUM.None +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilder.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilder.kt new file mode 100644 index 0000000000..dadfe910b1 --- /dev/null +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilder.kt @@ -0,0 +1,52 @@ +package com.tangem.common.ui.expressStatus.state + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.isNullOrEmpty +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList + +/** + * Builds express status subtitle by combining [activeStatus] with formatted [date]. + * + * Separator rules: + * - Relative date (MinutesAgo / HoursAgo PluralRes) — " ~ ". + * - Absolute date (Today / FullDate) — " "; Today's leading Res is decapitalized when a status + * prefix is present. + * - Either side empty — the other is returned as is (no modifications). + */ +fun buildExpressStatusSubtitle(activeStatus: TextReference, date: TextReference): TextReference { + val hasStatus = !activeStatus.isNullOrEmpty() + val hasDate = !date.isNullOrEmpty() + return when { + !hasStatus && !hasDate -> TextReference.EMPTY + hasStatus && !hasDate -> activeStatus + !hasStatus && hasDate -> date + else -> combineWithStatus(activeStatus, date) + } +} + +private fun combineWithStatus(status: TextReference, date: TextReference): TextReference { + val shouldUseTilde = date.isRelativeTimeAgo() + val separator = if (shouldUseTilde) stringReference(value = " ~ ") else stringReference(value = " ") + val datePart = if (shouldUseTilde) date else date.decapitalizeToday() + return TextReference.Combined(refs = wrappedList(status, separator, datePart)) +} + +private fun TextReference.isRelativeTimeAgo(): Boolean { + return this is TextReference.PluralRes && + (id == R.plurals.common_minutes_time_ago || id == R.plurals.common_hours_time_ago) +} + +private fun TextReference.decapitalizeToday(): TextReference { + if (this !is TextReference.Combined) return this + val patched = refs.data.map { ref -> + if (ref is TextReference.Res && ref.id == R.string.common_today) { + ref.copy(shouldDecapitalize = true) + } else { + ref + } + } + return TextReference.Combined(refs = WrappedList(data = patched)) +} \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt index fb2489e331..2b7035b44d 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/expressStatus/state/ExpressTransactionStateUM.kt @@ -28,6 +28,8 @@ data class ExpressTransactionStateInfoUM( val txExternalUrl: String?, val timestamp: Long, val timestampFormatted: TextReference, + val timestampAgoFormatted: TextReference, + val activeStatus: TextReference, val onGoToProviderClick: (String) -> Unit, val onClick: () -> Unit, val onDisposeExpressStatus: () -> Unit, @@ -36,12 +38,14 @@ data class ExpressTransactionStateInfoUM( val toFiatAmount: TextReference?, val toAmountSymbol: String, val toCurrencyIcon: CurrencyIconState, - val fromAmount: TextReference, val fromFiatAmount: TextReference?, val fromAmountSymbol: String, val fromCurrencyIcon: CurrencyIconState, -) +) { + val subtitle: TextReference + get() = buildExpressStatusSubtitle(activeStatus = activeStatus, date = timestampAgoFormatted) +} enum class ExpressTransactionStateIconUM { Warning, diff --git a/common/ui/src/test/kotlin/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilderTest.kt b/common/ui/src/test/kotlin/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilderTest.kt new file mode 100644 index 0000000000..04870d3d3b --- /dev/null +++ b/common/ui/src/test/kotlin/com/tangem/common/ui/expressStatus/state/ExpressStatusSubtitleBuilderTest.kt @@ -0,0 +1,117 @@ +package com.tangem.common.ui.expressStatus.state + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.utils.StringsSigns +import org.junit.jupiter.api.Test + +internal class ExpressStatusSubtitleBuilderTest { + + private val status = stringReference("Confirming") + + private val minutesAgo = TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 30, + formatArgs = wrappedList(30), + ) + + private val hoursAgo = TextReference.PluralRes( + id = R.plurals.common_hours_time_ago, + count = 3, + formatArgs = wrappedList(3), + ) + + private val today = TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str("03:00"), + ), + ), + ) + + private val fullDate = TextReference.Str("14 Oct 2025") + + @Test + fun `GIVEN empty activeStatus AND empty date WHEN buildExpressStatusSubtitle THEN return EMPTY`() { + val result = buildExpressStatusSubtitle( + activeStatus = TextReference.EMPTY, + date = TextReference.EMPTY, + ) + + assertThat(result).isEqualTo(TextReference.EMPTY) + } + + @Test + fun `GIVEN non-empty activeStatus AND empty date WHEN buildExpressStatusSubtitle THEN return activeStatus`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = TextReference.EMPTY) + + assertThat(result).isEqualTo(status) + } + + @Test + fun `GIVEN empty activeStatus AND MinutesAgo date WHEN buildExpressStatusSubtitle THEN return date as is`() { + val result = buildExpressStatusSubtitle(activeStatus = TextReference.EMPTY, date = minutesAgo) + + assertThat(result).isEqualTo(minutesAgo) + } + + @Test + fun `GIVEN status AND MinutesAgo date WHEN buildExpressStatusSubtitle THEN return Combined with tilde separator`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = minutesAgo) + + assertThat(result).isEqualTo( + TextReference.Combined(refs = wrappedList(status, stringReference(" ~ "), minutesAgo)), + ) + } + + @Test + fun `GIVEN status AND HoursAgo date WHEN buildExpressStatusSubtitle THEN return Combined with tilde separator`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = hoursAgo) + + assertThat(result).isEqualTo( + TextReference.Combined(refs = wrappedList(status, stringReference(" ~ "), hoursAgo)), + ) + } + + @Test + fun `GIVEN status AND Today date WHEN buildExpressStatusSubtitle THEN return Combined with space AND decapitalized today`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = today) + + val expectedToday = TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(id = R.string.common_today, shouldDecapitalize = true), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str("03:00"), + ), + ), + ) + assertThat(result).isEqualTo( + TextReference.Combined(refs = wrappedList(status, stringReference(" "), expectedToday)), + ) + } + + @Test + fun `GIVEN status AND FullDate date WHEN buildExpressStatusSubtitle THEN return Combined with space separator`() { + val result = buildExpressStatusSubtitle(activeStatus = status, date = fullDate) + + assertThat(result).isEqualTo( + TextReference.Combined(refs = wrappedList(status, stringReference(" "), fullDate)), + ) + } + + @Test + fun `GIVEN empty activeStatus AND Today date WHEN buildExpressStatusSubtitle THEN return Today as is without decapitalize`() { + val result = buildExpressStatusSubtitle(activeStatus = TextReference.EMPTY, date = today) + + assertThat(result).isEqualTo(today) + } +} \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 58c1e0cb0f..10573a0d9d 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -66,6 +66,10 @@ abstract class VerifyDesignTokensTask : DefaultTask() { } } +tasks.withType().configureEach { + useJUnitPlatform() +} + android { namespace = "com.tangem.core.ui" @@ -149,4 +153,5 @@ dependencies { testImplementation(deps.test.truth) testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) + testRuntimeOnly(deps.test.junit5.vintage.engine) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt index 9354511464..6c5be9aecd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DateUtils.kt @@ -5,6 +5,7 @@ import com.tangem.utils.extensions.isToday import com.tangem.utils.extensions.isYesterday import org.joda.time.DateTime import org.joda.time.DateTimeZone +import org.joda.time.LocalDate import org.joda.time.format.DateTimeFormatter /** @@ -46,16 +47,26 @@ fun Long.formatAsDateTime(formatter: DateTimeFormatter): String { * @param now The current date to compare against. * @return A [FormattedDate] subclass. */ -@Suppress("MagicNumber") fun getFormattedDate(createdAt: String, now: DateTime): FormattedDate { val pastDateUtc = try { DateTime.parse(createdAt) } catch (_: Exception) { return FormattedDate.FullDate(createdAt) } + return getFormattedDate(pastDateUtc = pastDateUtc, now = now) +} +/** + * Compares the given past date to [now] and returns a [FormattedDate] describing the difference. + * + * @param pastDateUtc Past date in UTC. + * @param now The current date to compare against. + */ +@Suppress("MagicNumber") +fun getFormattedDate(pastDateUtc: DateTime, now: DateTime): FormattedDate { val pastDateLocal = pastDateUtc.withZone(DateTimeZone.getDefault()) - val isToday = pastDateLocal.isToday() + val nowLocal = now.withZone(DateTimeZone.getDefault()) + val isToday = LocalDate(pastDateLocal) == LocalDate(nowLocal) val diffInMillis = now.millis - pastDateUtc.millis val diffInMinutes = diffInMillis / (1000 * 60) diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/FormattedDateMapper.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/FormattedDateMapper.kt new file mode 100644 index 0000000000..ef715af57c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/FormattedDateMapper.kt @@ -0,0 +1,55 @@ +package com.tangem.core.ui.utils + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.utils.StringsSigns +import org.joda.time.DateTime +import org.joda.time.DateTimeZone + +/** + * Maps an ISO 8601 date string into a [TextReference] with a human-friendly "time ago" label. + */ +fun mapFormattedDate(createdAt: String, now: DateTime = DateTime.now()): TextReference { + val formattedDate = runCatching { + getFormattedDate(createdAt = createdAt, now = now) + }.getOrElse { FormattedDate.FullDate(createdAt) } + + return formattedDate.toTextReference() +} + +/** + * Maps an epoch millisecond [timestamp] into a [TextReference] with a human-friendly "time ago" label. + */ +fun mapFormattedDate(timestamp: Long, now: DateTime = DateTime.now()): TextReference { + val formattedDate = runCatching { + getFormattedDate(pastDateUtc = DateTime(timestamp, DateTimeZone.UTC), now = now) + }.getOrElse { FormattedDate.FullDate(timestamp.toString()) } + + return formattedDate.toTextReference() +} + +private fun FormattedDate.toTextReference(): TextReference = when (this) { + is FormattedDate.FullDate -> TextReference.Str(value = date) + is FormattedDate.HoursAgo -> TextReference.PluralRes( + id = R.plurals.common_hours_time_ago, + count = hours, + formatArgs = wrappedList(hours), + ) + is FormattedDate.MinutesAgo -> TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = minutes, + formatArgs = wrappedList(minutes), + ) + is FormattedDate.Today -> TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str(time), + ), + ), + ) +} \ No newline at end of file diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt index 7fa8bcd9ce..0be823f7d9 100644 --- a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateTimeFormattersTest.kt @@ -1,6 +1,12 @@ package com.tangem.core.ui.utils +import android.text.format.DateFormat import com.google.common.truth.Truth +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance @@ -11,6 +17,17 @@ import org.junit.jupiter.api.TestInstance @TestInstance(TestInstance.Lifecycle.PER_CLASS) class DateTimeFormattersTest { + @BeforeEach + fun setUp() { + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + } + + @AfterEach + fun tearDown() { + unmockkStatic(DateFormat::class) + } + @Test fun `converts LLLL to MMMM - full standalone month pattern that crashes on Chinese locale`() { // Arrange diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt index 6e145b2371..626df6c6b2 100644 --- a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/DateUtilsTest.kt @@ -1,8 +1,15 @@ package com.tangem.core.ui.utils +import android.text.format.DateFormat import com.google.common.truth.Truth +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic import org.joda.time.DateTime import org.joda.time.DateTimeZone +import org.joda.time.format.DateTimeFormatterBuilder import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -19,11 +26,23 @@ class DateUtilsTest { fun setUp() { defaultTimeZone = DateTimeZone.getDefault() DateTimeZone.setDefault(DateTimeZone.forID("Europe/Moscow")) + + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + + mockkObject(DateTimeFormatters) + every { DateTimeFormatters.timeFormatter } returns DateTimeFormatterBuilder() + .appendHourOfDay(2) + .appendLiteral(':') + .appendMinuteOfHour(2) + .toFormatter() } @AfterEach fun tearDown() { DateTimeZone.setDefault(defaultTimeZone) + unmockkObject(DateTimeFormatters) + unmockkStatic(DateFormat::class) } @Test diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/utils/FormattedDateMapperTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/FormattedDateMapperTest.kt new file mode 100644 index 0000000000..4e6ebc7f46 --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/utils/FormattedDateMapperTest.kt @@ -0,0 +1,234 @@ +package com.tangem.core.ui.utils + +import android.text.format.DateFormat +import com.google.common.truth.Truth +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.WrappedList +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.utils.StringsSigns +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.joda.time.format.DateTimeFormatterBuilder +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FormattedDateMapperTest { + + private lateinit var defaultTimeZone: DateTimeZone + + private val now = createDateTime(day = 14, hour = 12) + + @BeforeEach + fun setUp() { + defaultTimeZone = DateTimeZone.getDefault() + DateTimeZone.setDefault(DateTimeZone.forID("Europe/Moscow")) + + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + + mockkObject(DateTimeFormatters) + every { DateTimeFormatters.timeFormatter } returns DateTimeFormatterBuilder() + .appendHourOfDay(2) + .appendLiteral(':') + .appendMinuteOfHour(2) + .toFormatter() + } + + @AfterEach + fun tearDown() { + DateTimeZone.setDefault(defaultTimeZone) + unmockkObject(DateTimeFormatters) + unmockkStatic(DateFormat::class) + } + + // region String overload + + @Test + fun `GIVEN iso string less than a minute ago WHEN mapFormattedDate THEN return minutes PluralRes with count 1`() { + val createdAt = now.minusSeconds(30).toString() + + val result = mapFormattedDate(createdAt = createdAt, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 1, + formatArgs = wrappedList(1), + ), + ) + } + + @Test + fun `GIVEN iso string 30 minutes ago WHEN mapFormattedDate THEN return minutes PluralRes with count 30`() { + val createdAt = now.minusMinutes(30).toString() + + val result = mapFormattedDate(createdAt = createdAt, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 30, + formatArgs = wrappedList(30), + ), + ) + } + + @Test + fun `GIVEN iso string 3 hours ago today WHEN mapFormattedDate THEN return hours PluralRes with count 3`() { + val createdAt = now.minusHours(3).toString() + + val result = mapFormattedDate(createdAt = createdAt, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_hours_time_ago, + count = 3, + formatArgs = wrappedList(3), + ), + ) + } + + @Test + fun `GIVEN iso string today but past 12 hours WHEN mapFormattedDate THEN return Combined with today and local time`() { + val pastDate = createDateTime(day = 14, hour = 0) + val createdAt = pastDate.toString() + val nowInTest = createDateTime(day = 14, hour = 12) + + val result = mapFormattedDate(createdAt = createdAt, now = nowInTest) + + Truth.assertThat(result).isEqualTo( + TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str("03:00"), + ), + ), + ), + ) + } + + @Test + fun `GIVEN iso string from a previous day WHEN mapFormattedDate THEN return Str FullDate`() { + val pastDate = createDateTime(day = 10, hour = 9) + val createdAt = pastDate.toString() + + val result = mapFormattedDate(createdAt = createdAt, now = now) + + Truth.assertThat(result).isInstanceOf(TextReference.Str::class.java) + } + + @Test + fun `GIVEN malformed iso string WHEN mapFormattedDate THEN return Str with original value`() { + val malformed = "2025/10/14T12:00:00.000Z" + + val result = mapFormattedDate(createdAt = malformed, now = now) + + Truth.assertThat(result).isEqualTo(TextReference.Str(value = malformed)) + } + + // endregion + + // region Long overload + + @Test + fun `GIVEN timestamp less than a minute ago WHEN mapFormattedDate THEN return minutes PluralRes with count 1`() { + val timestamp = now.minusSeconds(30).millis + + val result = mapFormattedDate(timestamp = timestamp, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 1, + formatArgs = wrappedList(1), + ), + ) + } + + @Test + fun `GIVEN timestamp 45 minutes ago WHEN mapFormattedDate THEN return minutes PluralRes with count 45`() { + val timestamp = now.minusMinutes(45).millis + + val result = mapFormattedDate(timestamp = timestamp, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_minutes_time_ago, + count = 45, + formatArgs = wrappedList(45), + ), + ) + } + + @Test + fun `GIVEN timestamp 5 hours ago today WHEN mapFormattedDate THEN return hours PluralRes with count 5`() { + val timestamp = now.minusHours(5).millis + + val result = mapFormattedDate(timestamp = timestamp, now = now) + + Truth.assertThat(result).isEqualTo( + TextReference.PluralRes( + id = R.plurals.common_hours_time_ago, + count = 5, + formatArgs = wrappedList(5), + ), + ) + } + + @Test + fun `GIVEN timestamp today but past 12 hours WHEN mapFormattedDate THEN return Combined with today and local time`() { + val pastDate = createDateTime(day = 14, hour = 0) + val nowInTest = createDateTime(day = 14, hour = 12) + + val result = mapFormattedDate(timestamp = pastDate.millis, now = nowInTest) + + Truth.assertThat(result).isEqualTo( + TextReference.Combined( + refs = WrappedList( + data = listOf( + TextReference.Res(R.string.common_today), + TextReference.Str(StringsSigns.COMA_SIGN), + TextReference.Str(StringsSigns.WHITE_SPACE), + TextReference.Str("03:00"), + ), + ), + ), + ) + } + + @Test + fun `GIVEN timestamp from a previous day WHEN mapFormattedDate THEN return Str FullDate`() { + val pastDate = createDateTime(day = 10, hour = 9) + + val result = mapFormattedDate(timestamp = pastDate.millis, now = now) + + Truth.assertThat(result).isInstanceOf(TextReference.Str::class.java) + } + + // endregion + + private fun createDateTime(day: Int, hour: Int): DateTime { + return DateTime( + /* year = */ 2025, + /* monthOfYear = */ 10, + /* dayOfMonth = */ day, + /* hourOfDay = */ hour, + /* minuteOfHour = */ 0, + /* secondOfMinute = */ 0, + /* millisOfSecond = */ 0, + /* zone = */ DateTimeZone.UTC, + ) + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt index b565dd823c..1fde08923e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/converter/ShortArticleToArticleConfigUMConverter.kt @@ -7,7 +7,7 @@ import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.ShortArticle import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM -import com.tangem.features.feed.ui.utils.mapFormattedDate +import com.tangem.core.ui.utils.mapFormattedDate import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt index a966da27f2..d2cdcd64d7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/details/converter/NewsDetailsConverter.kt @@ -4,23 +4,17 @@ import androidx.compose.runtime.Stable import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.WrappedList -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.utils.FormattedDate -import com.tangem.core.ui.utils.getFormattedDate +import com.tangem.core.ui.utils.mapFormattedDate import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.news.DetailedArticle import com.tangem.domain.models.news.RelatedArticle -import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.news.details.state.ArticleUM import com.tangem.features.feed.ui.news.details.state.Media import com.tangem.features.feed.ui.news.details.state.RelatedArticleUM -import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import org.joda.time.DateTime @Stable internal class NewsDetailsConverter( @@ -74,34 +68,4 @@ internal class NewsDetailsConverter( ) }.toImmutableList() } - - private fun mapFormattedDate(createdAt: String): TextReference { - val formattedDate = getFormattedDate( - createdAt = createdAt, - now = DateTime.now(), - ) - return when (formattedDate) { - is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) - is FormattedDate.HoursAgo -> TextReference.PluralRes( - id = R.plurals.news_published_hours_ago, - count = formattedDate.hours, - formatArgs = wrappedList(formattedDate.hours), - ) - is FormattedDate.MinutesAgo -> TextReference.PluralRes( - id = R.plurals.news_published_minutes_ago, - count = formattedDate.minutes, - formatArgs = wrappedList(formattedDate.minutes), - ) - is FormattedDate.Today -> TextReference.Combined( - refs = WrappedList( - data = listOf( - TextReference.Res(R.string.common_today), - TextReference.Str(StringsSigns.COMA_SIGN), - TextReference.Str(StringsSigns.WHITE_SPACE), - TextReference.Str(formattedDate.time), - ), - ), - ) - } - } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt deleted file mode 100644 index ef92d71b3c..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/utils/CreatedTimeFormatter.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.features.feed.ui.utils - -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.WrappedList -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.utils.FormattedDate -import com.tangem.core.ui.utils.getFormattedDate -import com.tangem.features.feed.impl.R -import com.tangem.utils.StringsSigns -import org.joda.time.DateTime - -internal fun mapFormattedDate(createdAt: String): TextReference { - val formattedDate = runCatching { - getFormattedDate( - createdAt = createdAt, - now = DateTime.now(), - ) - }.getOrElse { FormattedDate.FullDate("") } - - return when (formattedDate) { - is FormattedDate.FullDate -> TextReference.Str(value = formattedDate.date) - is FormattedDate.HoursAgo -> TextReference.PluralRes( - id = R.plurals.news_published_hours_ago, - count = formattedDate.hours, - formatArgs = wrappedList(formattedDate.hours), - ) - is FormattedDate.MinutesAgo -> TextReference.PluralRes( - id = R.plurals.news_published_minutes_ago, - count = formattedDate.minutes, - formatArgs = wrappedList(formattedDate.minutes), - ) - is FormattedDate.Today -> TextReference.Combined( - refs = WrappedList( - data = listOf( - TextReference.Res(R.string.common_today), - TextReference.Str(StringsSigns.COMA_SIGN), - TextReference.Str(StringsSigns.WHITE_SPACE), - TextReference.Str(formattedDate.time), - ), - ), - ) - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt index 2181819a15..e2e0af401f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsOnrampTransactionStateConverter.kt @@ -1,6 +1,8 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory import com.tangem.common.ui.expressStatus.state.* +import com.tangem.common.ui.expressStatus.toActiveStatusText +import com.tangem.common.ui.expressStatus.toIconState import com.tangem.common.ui.notifications.ExpressNotificationsUM import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -12,6 +14,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.mapFormattedDate import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.appcurrency.model.AppCurrency @@ -55,6 +58,8 @@ internal class TokenDetailsOnrampTransactionStateConverter( value.timestamp.toTimeFormat(), ), ), + timestampAgoFormatted = mapFormattedDate(value.timestamp), + activeStatus = value.status.toActiveStatusText(cryptoCurrency.name), toAmount = stringReference( value.toAmount.format { crypto(cryptoCurrency) }, ), @@ -82,7 +87,7 @@ internal class TokenDetailsOnrampTransactionStateConverter( url = value.fromCurrency.image, fallbackResId = R.drawable.ic_currency_24, ), - iconState = getIconState(value.status), + iconState = value.status.toIconState(), onGoToProviderClick = { url -> analyticsEventHandler.send(TokenOnrampAnalyticsEvent.GoToProvider()) clickIntents.onGoToProviderClick(url) @@ -124,18 +129,6 @@ internal class TokenDetailsOnrampTransactionStateConverter( null } - private fun getIconState(status: OnrampStatus.Status): ExpressTransactionStateIconUM { - return when (status) { - OnrampStatus.Status.RefundInProgress, - OnrampStatus.Status.Verifying, - -> ExpressTransactionStateIconUM.Warning - OnrampStatus.Status.Refunded, - OnrampStatus.Status.Failed, - -> ExpressTransactionStateIconUM.Error - else -> ExpressTransactionStateIconUM.None - } - } - private fun convertStatuses(status: OnrampStatus.Status, externalTxUrl: String?): ExpressStatusUM { val statuses = with(status) { persistentListOf( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt index e5d7a1341b..454a03e171 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSwapTransactionsStateConverter.kt @@ -13,6 +13,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.mapFormattedDate import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday import com.tangem.core.ui.utils.toTimeFormat import com.tangem.domain.appcurrency.model.AppCurrency @@ -143,6 +144,7 @@ internal class TokenDetailsSwapTransactionsStateConverter( info = tx.info.copy( txExternalId = statusModel.txExternalId, txExternalUrl = statusModel.txExternalUrl, + activeStatus = getActiveStatusText(statusModel.status), ), ) } @@ -164,6 +166,8 @@ internal class TokenDetailsSwapTransactionsStateConverter( timestampFormatted = stringReference( "${timestamp.toDateFormatWithTodayYesterday()}, ${timestamp.toTimeFormat()}", ), + timestampAgoFormatted = mapFormattedDate(timestamp), + activeStatus = getActiveStatusText(transaction.status?.status), toAmount = getCryptoAmount(transaction.toCryptoAmount, toCryptoCurrency), toFiatAmount = getFiatAmount(toFiatAmount), toCurrencyIcon = iconStateConverter.convert(toCryptoCurrency), @@ -251,6 +255,26 @@ internal class TokenDetailsSwapTransactionsStateConverter( } } + private fun getActiveStatusText(status: ExchangeStatus?): TextReference = when (status) { + ExchangeStatus.New, + ExchangeStatus.Waiting, + -> resourceReference(R.string.express_exchange_status_receiving_active) + ExchangeStatus.WaitingTxHash -> resourceReference(R.string.express_exchange_status_waiting_tx_hash) + ExchangeStatus.Confirming -> resourceReference(R.string.express_exchange_status_confirming_active) + ExchangeStatus.Verifying -> resourceReference(R.string.express_exchange_status_verifying) + ExchangeStatus.Exchanging -> resourceReference(R.string.express_exchange_status_exchanging_active) + ExchangeStatus.Sending -> resourceReference(R.string.express_exchange_status_sending_active) + ExchangeStatus.Finished -> resourceReference(R.string.express_exchange_status_sent) + ExchangeStatus.Refunded -> resourceReference(R.string.express_exchange_status_refunded) + ExchangeStatus.Paused -> resourceReference(R.string.express_exchange_status_paused) + ExchangeStatus.Cancelled -> resourceReference(R.string.express_exchange_status_canceled) + ExchangeStatus.Failed, + ExchangeStatus.TxFailed, + ExchangeStatus.Unknown, + -> resourceReference(R.string.express_exchange_status_failed) + null -> TextReference.EMPTY + } + private fun getIconState(status: ExchangeStatus?): ExpressTransactionStateIconUM { return when (status) { ExchangeStatus.Verifying -> ExpressTransactionStateIconUM.Warning diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt index dfcff712d9..1a0a125cb4 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/ExpressStatusBottomSheetStateProvider.kt @@ -42,6 +42,8 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider analyticsEventHandler.send(TokenOnrampAnalyticsEvent.GoToProvider()) clickIntents.onGoToProviderClick(url) @@ -134,18 +140,6 @@ internal class SingleWalletOnrampTransactionConverter( null } - private fun getIconState(status: OnrampStatus.Status): ExpressTransactionStateIconUM { - return when (status) { - OnrampStatus.Status.Verifying, - OnrampStatus.Status.RefundInProgress, - -> ExpressTransactionStateIconUM.Warning - OnrampStatus.Status.Refunded, - OnrampStatus.Status.Failed, - -> ExpressTransactionStateIconUM.Error - else -> ExpressTransactionStateIconUM.None - } - } - private fun convertStatuses(status: OnrampStatus.Status, externalTxUrl: String?): ExpressStatusUM { val statuses = with(status) { persistentListOf( From 7095ceec0fdd043fe6e2ac2204a256ae1d9e2d92 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Apr 2026 16:01:28 +0200 Subject: [PATCH 129/206] Updated on 2026-08-14 --- .../tokenselector/TokenSelectorBottomSheet.kt | 50 ++----- .../common/ui/addtoken/AddTokenContentV2.kt | 1 + .../bottomsheets/TangemBottomSheet.kt | 110 +++++++++++---- .../tangem/core/ui/ds/badge/TangemBadge.kt | 18 ++- .../tangem/core/ui/ds/badge/TangemBadgeUM.kt | 19 +-- .../main/res/drawable/ic_arrow_down_20.xml | 9 ++ .../AddToPortfolioBottomSheetFooter.kt | 110 +++++++++++++++ .../AddToPortfolioBottomSheetSwitch.kt | 34 +++++ .../AddToPortfolioBottomSheetV2.kt | 126 ++++++++++++++++++ .../DefaultAddToPortfolioComponent.kt | 7 +- .../AddToPortfolioInitialSelectionResolver.kt | 62 +++++++-- .../model/AddToPortfolioModel.kt | 26 ++-- .../model/AddToPortfolioRouteUiSpec.kt | 57 ++++++++ .../model/TokenActionsUiBuilder.kt | 1 + .../ui/TokenActionsContentV2.kt | 17 ++- .../DefaultUserPortfolioComponent.kt | 11 +- .../DefaultPortfolioSelectorComponent.kt | 5 + ...ToPortfolioInitialSelectionResolverTest.kt | 110 +++++++++++++++ .../portfolioblock/ui/PortfolioBlock.kt | 104 +++++++-------- .../tangem/features/feed/ui/EntryContent.kt | 7 + .../detailed/MarketsTokenDetailsContent.kt | 16 +-- 21 files changed, 717 insertions(+), 183 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_arrow_down_20.xml create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetFooter.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetSwitch.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetV2.kt create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt index b9d7920386..2aff7dd2e3 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/tokenselector/TokenSelectorBottomSheet.kt @@ -25,10 +25,8 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.components.haze.hazeSourceTangem -import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBarType -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme @@ -38,7 +36,7 @@ import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.rememberHazeState @Composable -fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig, stickyFooter: StickyFooter? = null) { +fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig) { TangemBottomSheet( config = config, type = TangemBottomSheetType.Modal, @@ -47,56 +45,52 @@ fun TokenSelectorBottomSheet(config: TangemBottomSheetConfig, stickyFooter: Stic TokenSelectorContent( content = content, onDismiss = config.onDismissRequest, - stickyFooter = stickyFooter, embedded = false, ) }, ) } -data class StickyFooter(val buttonText: TextReference, val isEnabled: Boolean = true, val onClick: () -> Unit) - @Composable fun TokenSelectorEmbeddedContent( content: TokenSelectorContentUM, - stickyFooter: StickyFooter?, + scrollBottomInset: Dp, modifier: Modifier = Modifier, ) { TokenSelectorContent( content = content, - onDismiss = {}, - stickyFooter = stickyFooter, embedded = true, modifier = modifier, + scrollBottomInset = scrollBottomInset, ) } @Composable private fun TokenSelectorContent( content: TokenSelectorContentUM, - onDismiss: () -> Unit, - stickyFooter: StickyFooter?, embedded: Boolean, modifier: Modifier = Modifier, + scrollBottomInset: Dp = 0.dp, + onDismiss: () -> Unit = {}, ) { val hazeState = rememberHazeState() var topBarHeight by remember { mutableStateOf(0.dp) } val topContentPadding = if (embedded) { - TangemTheme.dimens2.x4 + 0.dp } else { topBarHeight } - val footerHeight = TangemTheme.dimens2.x14 + TangemTheme.dimens2.x4 - val listBottomPadding = TangemTheme.dimens2.x10 + if (stickyFooter != null) footerHeight else 0.dp Box(modifier = modifier.fillMaxWidth()) { + val bottomFadeReserve = if (embedded) 0.dp else TangemTheme.dimens2.x10 + val bottomListPadding = bottomFadeReserve + scrollBottomInset LazyColumn( modifier = Modifier.hazeSourceTangem(state = hazeState, 1f), contentPadding = PaddingValues( start = TangemTheme.dimens2.x4, end = TangemTheme.dimens2.x4, top = topContentPadding, - bottom = listBottomPadding, + bottom = bottomListPadding, ), ) { tokenSelectorSectionItems(content.sections) @@ -108,29 +102,11 @@ private fun TokenSelectorContent( hazeState = hazeState, onChangeHeight = { topBarHeight = it }, ) - } - Fade( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(bottom = if (stickyFooter != null) footerHeight else 0.dp), - height = TangemTheme.dimens2.x10, - ) - if (stickyFooter != null) { - TangemButton( + Fade( modifier = Modifier - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x2) - .fillMaxWidth(), - buttonUM = TangemButtonUM( - type = TangemButtonType.Primary, - text = stickyFooter.buttonText, - shape = TangemButtonShape.Rounded, - size = TangemButtonSize.X15, - isEnabled = stickyFooter.isEnabled, - onClick = stickyFooter.onClick, - ), + .fillMaxWidth() + .align(Alignment.BottomCenter), + height = TangemTheme.dimens2.x10, ) } } diff --git a/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt index 701d39d6c8..2f5e65b38a 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/addtoken/AddTokenContentV2.kt @@ -191,6 +191,7 @@ private fun AddButton(state: AddTokenUM.Button, modifier: Modifier = Modifier) { isEnabled = state.isEnabled, size = TangemButtonSize.X12, shape = TangemButtonShape.Rounded, + isLoading = state.showProgress, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt index 60b4d4699a..ce93e4cc75 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/TangemBottomSheet.kt @@ -10,10 +10,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -36,6 +37,14 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.WindowInsetsZero +/** + * Extra bottom inset that scrollable content under a [BasicBottomSheet] should reserve so it + * does not collide with the footer overlay: measured footer height plus the bottom gradient + * (see `gradientHeight` in [FooterOverlay]). Equals `0.dp` when there is no footer or when read + * outside [BasicBottomSheet]. + */ +val LocalTangemBottomSheetContentBottomInset = compositionLocalOf { 0.dp } + /** * Type of [TangemBottomSheet] that defines its behavior and appearance. * - [Default]: Standard bottom sheet with a draggable header @@ -204,6 +213,9 @@ inline fun BasicBottomSheet( ) { val model = config.content as? T ?: return val windowSize = LocalWindowSize.current + val density = LocalDensity.current + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + var footerHeightDp by remember { mutableStateOf(null) } val bsContent: @Composable ColumnScope.() -> Unit = { val maxHeight = when (type) { @@ -212,17 +224,19 @@ inline fun BasicBottomSheet( } val contentModifier = when (type) { - Default -> Modifier.clip( - RoundedCornerShape( - topStart = TangemTheme.dimens2.x8, - topEnd = TangemTheme.dimens2.x8, - ), - ) + Default -> Modifier + .padding(bottom = bottomBarHeight) + .clip( + RoundedCornerShape( + topStart = TangemTheme.dimens2.x8, + topEnd = TangemTheme.dimens2.x8, + ), + ) Modal -> Modifier .padding( start = TangemTheme.dimens2.x2, end = TangemTheme.dimens2.x2, - bottom = TangemTheme.dimens2.x2, + bottom = bottomBarHeight, ) .clip(RoundedCornerShape(TangemTheme.dimens2.x8)) } @@ -236,26 +250,19 @@ inline fun BasicBottomSheet( title(model) } Box(modifier = Modifier.fillMaxWidth()) { - content(model) if (footer != null) { - BottomFade( - modifier = Modifier.align(Alignment.BottomCenter), - gradientBrush = Brush.verticalGradient( - listOf( - TangemTheme.colors2.shadow.fadeMin, - TangemTheme.colors2.shadow.fadeMax, - ), - ), + FooterOverlay( + measuredFooterHeight = footerHeightDp, + onMeasureFooter = { newHeight -> + if (newHeight != footerHeightDp) { + footerHeightDp = newHeight + } + }, + footer = { footer(model) }, + content = { content(model) }, ) - } - Box( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - ) { - if (footer != null) { - footer(model) - } + } else { + content(model) } } } @@ -274,6 +281,57 @@ inline fun BasicBottomSheet( ) } +@Composable +fun BoxScope.FooterOverlay( + measuredFooterHeight: Dp?, + onMeasureFooter: (Dp) -> Unit, + footer: @Composable BoxScope.() -> Unit, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val gradientHeight = TangemTheme.dimens2.x10 + val isFooterRendered = measuredFooterHeight == null || measuredFooterHeight > 0.dp + val contentBottomOverlayHeight = if (isFooterRendered) { + (measuredFooterHeight ?: 0.dp) + gradientHeight + } else { + 0.dp + } + val fadeMax = TangemTheme.colors2.surface.level2 + CompositionLocalProvider( + LocalTangemBottomSheetContentBottomInset provides contentBottomOverlayHeight, + ) { + content() + } + if (isFooterRendered) { + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) { + Fade( + backgroundColor = fadeMax, + height = gradientHeight, + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(measuredFooterHeight ?: 0.dp) + .background(fadeMax), + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .onGloballyPositioned { coordinates -> + onMeasureFooter(with(density) { coordinates.size.height.toDp() }) + }, + ) { + footer() + } +} + // region Preview @Suppress("LongMethod") @Composable diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt index bc59df4610..89d58c63d9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadge.kt @@ -46,6 +46,7 @@ fun TangemBadge(badgeUM: TangemBadgeUM, modifier: Modifier = Modifier) { color = badgeUM.color, type = badgeUM.type, iconPosition = badgeUM.iconPosition, + shouldRespectIconTint = badgeUM.shouldRespectIconTint, onClick = badgeUM.onClick, modifier = modifier, ) @@ -77,6 +78,7 @@ fun TangemBadge( color: TangemBadgeColor = TangemBadgeColor.Gray, type: TangemBadgeType = TangemBadgeType.Solid, iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.None, + shouldRespectIconTint: Boolean = false, onClick: (() -> Unit)? = null, ) { val iconColor = getIconColor(type = type, color = color) @@ -95,6 +97,7 @@ fun TangemBadge( iconPosition = iconPosition, size = size, iconColor = iconColor, + shouldRespectIconTint = shouldRespectIconTint, ) AnimatedVisibility( visible = text != null, @@ -113,6 +116,7 @@ fun TangemBadge( iconPosition = iconPosition, size = size, iconColor = iconColor, + shouldRespectIconTint = shouldRespectIconTint, ) } } @@ -123,6 +127,7 @@ private fun StartIcon( size: TangemBadgeSize, iconColor: Color, tangemIconUM: TangemIconUM? = null, + shouldRespectIconTint: Boolean = false, ) { AnimatedVisibility( visible = tangemIconUM != null && iconPosition != TangemBadgeIconPosition.End, @@ -139,7 +144,11 @@ private fun StartIcon( is TangemIconUM.Url, TangemIconUM.Empty, -> wrappedIconRes - is TangemIconUM.Icon -> wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) + is TangemIconUM.Icon -> if (shouldRespectIconTint) { + wrappedIconRes + } else { + wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) + } }, ) } @@ -151,6 +160,7 @@ private fun EndIcon( size: TangemBadgeSize, iconColor: Color, tangemIconUM: TangemIconUM? = null, + shouldRespectIconTint: Boolean = false, ) { AnimatedVisibility( visible = tangemIconUM != null && iconPosition == TangemBadgeIconPosition.End, @@ -167,7 +177,11 @@ private fun EndIcon( is TangemIconUM.Url, TangemIconUM.Empty, -> wrappedIconRes - is TangemIconUM.Icon -> wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) + is TangemIconUM.Icon -> if (shouldRespectIconTint) { + wrappedIconRes + } else { + wrappedIconRes.copy(tintReference = ColorReference2 { iconColor }) + } }, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt index 83843fbc3d..84cff62206 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/badge/TangemBadgeUM.kt @@ -7,14 +7,16 @@ import com.tangem.core.ui.extensions.TextReference /** * UI model for [TangemBadge] component * - * @param text TextReference for the badge label. - * @param tangemIconUM Model of representation for the icon to be displayed in the badge. - * @param size [TangemBadgeSize] defining the size of the badge. - * @param shape [TangemBadgeShape] defining the shape of the badge. - * @param color [TangemBadgeColor] defining the color scheme of the badge. - * @param type [TangemBadgeType] defining the style of the badge. - * @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge. - * @param onClick Lambda to be invoked when the badge is clicked (optional). + * @param text TextReference for the badge label. + * @param tangemIconUM Model of representation for the icon to be displayed in the badge. + * @param size [TangemBadgeSize] defining the size of the badge. + * @param shape [TangemBadgeShape] defining the shape of the badge. + * @param color [TangemBadgeColor] defining the color scheme of the badge. + * @param type [TangemBadgeType] defining the style of the badge. + * @param iconPosition [TangemBadgeIconPosition] defining icon position of the badge. + * @param shouldRespectIconTint When true, the icon's own tintReference is preserved instead of being overridden by the + * badge color scheme. Useful when the icon carries its own semantic color (e.g. account icons). + * @param onClick Lambda to be invoked when the badge is clicked (optional). */ class TangemBadgeUM( val text: TextReference, @@ -24,5 +26,6 @@ class TangemBadgeUM( val color: TangemBadgeColor = TangemBadgeColor.Gray, val type: TangemBadgeType = TangemBadgeType.Solid, val iconPosition: TangemBadgeIconPosition = TangemBadgeIconPosition.Start, + val shouldRespectIconTint: Boolean = false, val onClick: (() -> Unit)? = null, ) \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/ic_arrow_down_20.xml b/core/ui/src/main/res/drawable/ic_arrow_down_20.xml new file mode 100644 index 0000000000..f3206755ec --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_arrow_down_20.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetFooter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetFooter.kt new file mode 100644 index 0000000000..dac734d96d --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetFooter.kt @@ -0,0 +1,110 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.text.style.TextOverflow +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalHazeState +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioFooterKind +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM +import dev.chrisbanes.haze.rememberHazeState + +@Composable +internal fun AddToPortfolioBottomSheetFooter( + currentRoute: AddToPortfolioRoutes, + userPortfolioState: State?, + onBack: () -> Unit, + onAddFromUserPortfolioClick: (() -> Unit)?, +) { + when (currentRoute.uiSpec().footer) { + AddToPortfolioFooterKind.Cancel -> WithLocalHaze { + CancelFooterButton(onClick = onBack) + } + AddToPortfolioFooterKind.UserPortfolioAdd -> { + val state = userPortfolioState?.value ?: return + val onClick = onAddFromUserPortfolioClick ?: return + WithLocalHaze { + UserPortfolioAddFooter( + isEnabled = state.isAddEnabled, + onClick = onClick, + ) + } + } + AddToPortfolioFooterKind.None -> Unit + } +} + +@Composable +private fun WithLocalHaze(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalHazeState provides rememberHazeState(), content = content) +} + +@Composable +private fun CancelFooterButton(onClick: () -> Unit) { + SecondaryTangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x4), + onClick = onClick, + text = resourceReference(R.string.common_cancel), + size = TangemButtonSize.X12, + shape = TangemButtonShape.Rounded, + ) +} + +@Composable +private fun UserPortfolioAddFooter(isEnabled: Boolean, onClick: () -> Unit) { + TangemRowContainer( + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens2.x6, + vertical = TangemTheme.dimens2.x5, + ), + ) { + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = stringResourceSafe(R.string.common_add_token), + style = TangemTheme.typography2.bodyMedium16, + color = TangemTheme.colors2.text.neutral.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = stringResourceSafe(R.string.markets_token_add_subtitle), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + SecondaryTangemButton( + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x2), + onClick = onClick, + text = resourceReference(R.string.common_add), + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, + isEnabled = isEnabled, + ) + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetSwitch.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetSwitch.kt new file mode 100644 index 0000000000..6cb218fbc1 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetSwitch.kt @@ -0,0 +1,34 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.LocalRedesignEnabled +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM + +@Composable +internal fun AddToPortfolioBottomSheetSwitch( + childStack: State>, + onBack: () -> Unit, + onDismiss: () -> Unit, + userPortfolioState: State? = null, + onAddFromUserPortfolioClick: (() -> Unit)? = null, +) { + if (LocalRedesignEnabled.current) { + AddToPortfolioBottomSheetV2( + childStack = childStack, + onBack = onBack, + onDismiss = onDismiss, + userPortfolioState = userPortfolioState, + onAddFromUserPortfolioClick = onAddFromUserPortfolioClick, + ) + } else { + AddToPortfolioBottomSheet( + childStack = childStack, + onBack = onBack, + onDismiss = onDismiss, + ) + } +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetV2.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetV2.kt new file mode 100644 index 0000000000..866fc6456c --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/AddToPortfolioBottomSheetV2.kt @@ -0,0 +1,126 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.components.bottomsheets.* +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.AddToPortfolioRoutes +import com.tangem.features.commonfeatures.impl.addtoportfolio.model.uiSpec +import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioUM + +@Composable +internal fun AddToPortfolioBottomSheetV2( + childStack: State>, + onBack: () -> Unit, + onDismiss: () -> Unit, + userPortfolioState: State? = null, + onAddFromUserPortfolioClick: (() -> Unit)? = null, +) { + val stack by childStack + val contentStack = remember { mutableStateOf(stack) } + val isNotEmpty = stack.active.configuration != AddToPortfolioRoutes.Empty + if (isNotEmpty) { + contentStack.value = stack + } + + TangemBottomSheet( + onBack = onBack, + config = TangemBottomSheetConfig( + isShown = isNotEmpty, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + type = TangemBottomSheetType.Modal, + containerColor = TangemTheme.colors2.surface.level2, + title = { + AddToPortfolioBottomSheetTitle( + stack = stack, + onCloseClick = onDismiss, + ) + }, + content = { + AnimatedContent(targetState = contentStack.value, label = "Content Animation") { animatedStack -> + AddToPortfolioRouteContent(animatedStack = animatedStack) + } + }, + footer = { + AnimatedContent( + targetState = contentStack.value.active.configuration, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "Footer Animation", + ) { route -> + AddToPortfolioBottomSheetFooter( + currentRoute = route, + userPortfolioState = userPortfolioState, + onBack = onBack, + onAddFromUserPortfolioClick = onAddFromUserPortfolioClick, + ) + } + }, + ) +} + +@Composable +private fun AddToPortfolioRouteContent(animatedStack: ChildStack) { + val spec = animatedStack.active.configuration.uiSpec() + val baseModifier = if (spec.shouldApplyHorizontalPadding) { + Modifier.padding(horizontal = TangemTheme.dimens2.x4) + } else { + Modifier + } + if (spec.isScrollable) { + val bottomInset = LocalTangemBottomSheetContentBottomInset.current + val scrollBottomReserve = if (bottomInset > 0.dp) bottomInset else TangemTheme.dimens2.x4 + Column(modifier = baseModifier.verticalScroll(rememberScrollState())) { + animatedStack.active.instance.Content(modifier = Modifier) + Spacer(modifier = Modifier.height(scrollBottomReserve)) + } + } else { + animatedStack.active.instance.Content(modifier = baseModifier) + } +} + +@Composable +private fun AddToPortfolioBottomSheetTitle( + stack: ChildStack, + onCloseClick: () -> Unit, +) { + TangemTopBar( + title = stack.active.configuration.uiSpec().title, + type = TangemTopBarType.BottomSheet, + endContent = { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_close_24), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.primary, + modifier = Modifier + .size(TangemTheme.dimens2.x11) + .background( + color = TangemTheme.colors2.button.backgroundSecondary, + shape = CircleShape, + ) + .clickableSingle(onClick = onCloseClick) + .padding(TangemTheme.dimens2.x2), + ) + }, + ) +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt index 189c551c95..02e43ad3eb 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/DefaultAddToPortfolioComponent.kt @@ -1,6 +1,7 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.backStack @@ -83,10 +84,14 @@ internal class DefaultAddToPortfolioComponent @AssistedInject constructor( @Composable override fun BottomSheet() { - AddToPortfolioBottomSheet( + val userPortfolioState = model.userPortfolioStateController.uiState + .collectAsStateWithLifecycle() + AddToPortfolioBottomSheetSwitch( childStack = childStack.subscribeAsState(), onBack = ::onBack, onDismiss = ::dismiss, + userPortfolioState = userPortfolioState, + onAddFromUserPortfolioClick = model::onContinueFromUserPortfolio, ) } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt index dd8d751766..d266107e75 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolver.kt @@ -23,6 +23,7 @@ internal class AddToPortfolioInitialSelectionResolver @Inject constructor( selectedWallet: UserWallet?, tokenParams: RawMarketToken, accountToAdd: AvailableToAddAccount? = null, + preferredNetwork: TokenMarketInfo.Network? = null, ): InitialSelection? { if (availableToAddData.availableToAddWallets.isEmpty()) return null val fallbackNetwork = orderedNetworks.firstOrNull() ?: return null @@ -33,13 +34,13 @@ internal class AddToPortfolioInitialSelectionResolver @Inject constructor( val ownerWallet = walletOrder.firstOrNull { entry -> entry.availableToAddAccounts.values.any { it === accountToAdd } } ?: walletOrder.first() - val network = pickAddableNetwork( + val network = pickNetworkForExplicitAccount( userWallet = ownerWallet.userWallet, account = accountToAdd, orderedNetworks = orderedNetworks, tokenParams = tokenParams, + preferredNetwork = preferredNetwork, ) - ?: fallbackNetwork return InitialSelection(userWallet = ownerWallet.userWallet, account = accountToAdd, network = network) } @@ -81,6 +82,35 @@ internal class AddToPortfolioInitialSelectionResolver @Inject constructor( ?: walletEntry.availableToAddAccounts.values.firstOrNull() } + private suspend fun pickNetworkForExplicitAccount( + userWallet: UserWallet, + account: AvailableToAddAccount, + orderedNetworks: List, + tokenParams: RawMarketToken, + preferredNetwork: TokenMarketInfo.Network?, + ): TokenMarketInfo.Network { + if (preferredNetwork != null) { + val candidate = account.availableToAddNetworks + .firstOrNull { it.networkId == preferredNetwork.networkId } + if ( + candidate != null && hasDerivationFor( + userWallet = userWallet, + account = account, + network = candidate, + tokenParams = tokenParams, + ) + ) { + return candidate + } + } + return pickAddableNetwork( + userWallet = userWallet, + account = account, + orderedNetworks = orderedNetworks, + tokenParams = tokenParams, + ) ?: orderedNetworks.first() + } + private suspend fun pickAddableNetwork( userWallet: UserWallet, account: AvailableToAddAccount, @@ -92,18 +122,26 @@ internal class AddToPortfolioInitialSelectionResolver @Inject constructor( } if (availableOrdered.isEmpty()) return null - val derivationIndex = account.account.account.derivationIndex - val withDerivation = availableOrdered.filter { network -> - val currency = getTokenMarketCryptoCurrency( - userWalletId = userWallet.walletId, - tokenMarketParams = tokenParams, - network = network, - accountIndex = derivationIndex, - ) ?: return@filter false - networkHasDerivationUseCase(userWallet, currency.network).getOrElse { false } + val withDerivation = availableOrdered.firstOrNull { network -> + hasDerivationFor(userWallet = userWallet, account = account, network = network, tokenParams = tokenParams) } + return withDerivation ?: availableOrdered.first() + } - return withDerivation.firstOrNull() ?: availableOrdered.first() + private suspend fun hasDerivationFor( + userWallet: UserWallet, + account: AvailableToAddAccount, + network: TokenMarketInfo.Network, + tokenParams: RawMarketToken, + ): Boolean { + val derivationIndex = account.account.account.derivationIndex + val currency = getTokenMarketCryptoCurrency( + userWalletId = userWallet.walletId, + tokenMarketParams = tokenParams, + network = network, + accountIndex = derivationIndex, + ) ?: return false + return networkHasDerivationUseCase(userWallet, currency.network).getOrElse { false } } data class InitialSelection( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 62226b2e71..0aaee52e02 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -378,23 +378,17 @@ internal class AddToPortfolioModel @Inject constructor( // drop first selected portfolio or any selected before .drop(1) .map { newPortfolio -> - val currentNetwork = selectedNetwork.first().selectedNetwork - val availableToAddNetworks = newPortfolio.account.availableToAddNetworks - val isSelectedNetworkAvailableForNewPortfolio = availableToAddNetworks - .any { it.networkId == currentNetwork.networkId } + val rebuiltSelectedNetwork = selectionResolver.resolve( + availableToAddData = data, + orderedNetworks = orderedNetworks, + selectedWallet = globalSelectedWallet, + tokenParams = tokenParams, + accountToAdd = newPortfolio.account, + preferredNetwork = selectedNetwork.first().selectedNetwork, + )?.toSelectedNetwork() - if (!isSelectedNetworkAvailableForNewPortfolio) { - val selection = selectionResolver.resolve( - availableToAddData = data, - orderedNetworks = orderedNetworks, - selectedWallet = globalSelectedWallet, - tokenParams = tokenParams, - accountToAdd = newPortfolio.account, - ) - val newNetwork = selection?.toSelectedNetwork() - if (newNetwork != null) { - this.selectedNetwork.tryEmit(newNetwork) - } + if (rebuiltSelectedNetwork != null) { + this.selectedNetwork.tryEmit(rebuiltSelectedNetwork) } selectedPortfolio.tryEmit(newPortfolio) navigation.popToFirst() diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt new file mode 100644 index 0000000000..bd256958e8 --- /dev/null +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioRouteUiSpec.kt @@ -0,0 +1,57 @@ +package com.tangem.features.commonfeatures.impl.addtoportfolio.model + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.commonfeatures.impl.R + +internal data class AddToPortfolioRouteUiSpec( + val title: TextReference, + val isScrollable: Boolean, + val shouldApplyHorizontalPadding: Boolean, + val footer: AddToPortfolioFooterKind, +) + +internal enum class AddToPortfolioFooterKind { + None, + Cancel, + UserPortfolioAdd, +} + +internal fun AddToPortfolioRoutes.uiSpec(): AddToPortfolioRouteUiSpec = when (this) { + AddToPortfolioRoutes.AddToken -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.common_add_token), + isScrollable = true, + shouldApplyHorizontalPadding = true, + footer = AddToPortfolioFooterKind.None, + ) + is AddToPortfolioRoutes.NetworkSelector -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.common_add_token), + isScrollable = true, + shouldApplyHorizontalPadding = true, + footer = AddToPortfolioFooterKind.Cancel, + ) + AddToPortfolioRoutes.PortfolioSelector -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.common_add_token), + isScrollable = false, + shouldApplyHorizontalPadding = true, + footer = AddToPortfolioFooterKind.Cancel, + ) + AddToPortfolioRoutes.UserPortfolio -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.markets_portfolio_block_title), + isScrollable = false, + shouldApplyHorizontalPadding = false, + footer = AddToPortfolioFooterKind.UserPortfolioAdd, + ) + AddToPortfolioRoutes.TokenActions -> AddToPortfolioRouteUiSpec( + title = resourceReference(R.string.common_get_token), + isScrollable = true, + shouldApplyHorizontalPadding = true, + footer = AddToPortfolioFooterKind.None, + ) + AddToPortfolioRoutes.Empty -> AddToPortfolioRouteUiSpec( + title = TextReference.EMPTY, + isScrollable = false, + shouldApplyHorizontalPadding = false, + footer = AddToPortfolioFooterKind.None, + ) +} \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt index d1f169a0ae..b63304be34 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/TokenActionsUiBuilder.kt @@ -150,6 +150,7 @@ internal class TokenActionsUiBuilder @Inject constructor( } else { TangemBadgeIconPosition.End }, + shouldRespectIconTint = cryptoCurrencyData.isAccountMode, ) } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContentV2.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContentV2.kt index b5c7393d49..569d3233b4 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContentV2.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/ui/TokenActionsContentV2.kt @@ -44,6 +44,7 @@ import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.res.* import com.tangem.features.commonfeatures.impl.R import com.tangem.features.commonfeatures.impl.addtoportfolio.ui.state.TokenActionsUM +import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal import java.util.UUID @@ -79,13 +80,15 @@ internal fun TokenActionsContentV2(state: TokenActionsUM, modifier: Modifier = M SpacerH(TangemTheme.dimens2.x2) - SecondaryTangemButton( - modifier = Modifier.fillMaxWidth(), - onClick = state.onLaterClick, - text = resourceReference(R.string.common_later), - size = TangemButtonSize.X12, - shape = TangemButtonShape.Rounded, - ) + CompositionLocalProvider(LocalHazeState provides rememberHazeState()) { + SecondaryTangemButton( + modifier = Modifier.fillMaxWidth(), + onClick = state.onLaterClick, + text = resourceReference(R.string.common_later), + size = TangemButtonSize.X12, + shape = TangemButtonShape.Rounded, + ) + } } } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt index d09e0cfcaf..42d36a8582 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/userportfolio/DefaultUserPortfolioComponent.kt @@ -3,12 +3,10 @@ package com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tangem.common.ui.markets.tokenselector.StickyFooter import com.tangem.common.ui.markets.tokenselector.TokenSelectorEmbeddedContent import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.features.commonfeatures.impl.R +import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset import com.tangem.features.commonfeatures.impl.addtoportfolio.userportfolio.model.UserPortfolioModel import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -20,7 +18,6 @@ internal class DefaultUserPortfolioComponent @AssistedInject constructor( ) : UserPortfolioComponent, AppComponentContext by context { private val model: UserPortfolioModel = getOrCreateModel(params) - private val onContinueClick: () -> Unit = params.callbacks::onContinueFromUserPortfolio @Composable override fun Content(modifier: Modifier) { @@ -29,11 +26,7 @@ internal class DefaultUserPortfolioComponent @AssistedInject constructor( TokenSelectorEmbeddedContent( content = state.content, modifier = modifier, - stickyFooter = StickyFooter( - buttonText = resourceReference(R.string.common_add), - isEnabled = state.isAddEnabled, - onClick = onContinueClick, - ), + scrollBottomInset = LocalTangemBottomSheetContentBottomInset.current, ) } diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt index 1d3df6157b..3346d4017d 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/portfolioselector/DefaultPortfolioSelectorComponent.kt @@ -1,11 +1,13 @@ package com.tangem.features.commonfeatures.impl.portfolioselector +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.bottomsheets.LocalTangemBottomSheetContentBottomInset import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent @@ -45,15 +47,18 @@ internal class DefaultPortfolioSelectorComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() + val listBottomPadding = PaddingValues(bottom = LocalTangemBottomSheetContentBottomInset.current) if (LocalRedesignEnabled.current) { PortfolioSelectorContentV2( state = state, modifier = modifier, + contentPadding = listBottomPadding, ) } else { PortfolioSelectorContent( state = state, modifier = modifier, + contentPadding = listBottomPadding, ) } } diff --git a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt index 3e6307f8ed..0e42020f57 100644 --- a/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt +++ b/features/common-features/impl/src/test/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioInitialSelectionResolverTest.kt @@ -508,6 +508,109 @@ class AddToPortfolioInitialSelectionResolverTest { Truth.assertThat(result?.network).isEqualTo(BITCOIN) } + @Test + fun `GIVEN accountToAdd and preferredNetwork is on account and addable WHEN resolve THEN return preferred`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns true.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(BITCOIN, ETHEREUM), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + preferredNetwork = ETHEREUM, + ) + + Truth.assertThat(result?.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN accountToAdd and preferred is not on account WHEN resolve THEN use pickAddableNetwork`() = runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns true.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM, BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + preferredNetwork = POLYGON, + ) + + Truth.assertThat(result?.network).isEqualTo(ETHEREUM) + } + + @Test + fun `GIVEN accountToAdd and preferred on account has no derivation WHEN resolve THEN fall back to pickAddableNetwork`() = + runTest { + val walletId = UserWalletId(WALLET_ID_A) + val userWallet = userWallet(walletId) + val explicitAccount = availableAccount(availableToAddNetworks = setOf(ETHEREUM, BITCOIN)) + + val ethereumCurrency = cryptoCurrency() + val bitcoinCurrency = cryptoCurrency() + coEvery { getTokenMarketCryptoCurrency(any(), any(), ETHEREUM, any()) } returns ethereumCurrency + coEvery { getTokenMarketCryptoCurrency(any(), any(), BITCOIN, any()) } returns bitcoinCurrency + every { networkHasDerivationUseCase(any(), ethereumCurrency.network) } returns false.right() + every { networkHasDerivationUseCase(any(), bitcoinCurrency.network) } returns true.right() + + val data = availableData( + wallets = mapOf( + walletId to walletEntry( + userWallet = userWallet, + accounts = mapOf(AccountId.forMainCryptoPortfolio(walletId) to availableAccount()), + ), + ), + ) + + val result = resolver.resolve( + availableToAddData = data, + orderedNetworks = listOf(ETHEREUM, BITCOIN), + selectedWallet = userWallet, + tokenParams = tokenParams, + accountToAdd = explicitAccount, + preferredNetwork = ETHEREUM, + ) + + Truth.assertThat(result?.network).isEqualTo(BITCOIN) + } + @Test fun `GIVEN get token market crypto currency returns null WHEN resolve THEN fall back to first ordered available network`() = runTest { val walletId = UserWalletId(WALLET_ID_A) @@ -587,5 +690,12 @@ class AddToPortfolioInitialSelectionResolverTest { contractAddress = null, decimalCount = 8, ) + + val POLYGON = TokenMarketInfo.Network( + networkId = "polygon", + isExchangeable = true, + contractAddress = null, + decimalCount = 18, + ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt index e372da6cda..7ca136dcf8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/market/details/portfolioblock/ui/PortfolioBlock.kt @@ -10,7 +10,6 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetDefaults @@ -22,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layoutId import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -31,6 +31,8 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.LocalWindowSize import com.tangem.core.ui.res.TangemTheme @@ -56,6 +58,7 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi } AnimatedVisibility( + modifier = Modifier.align(Alignment.BottomCenter), visible = isVisible, enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)), exit = fadeOut(animationSpec = tween(durationMillis = 300)), @@ -91,62 +94,55 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi @Composable private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = Modifier) { - FloatingCard(modifier = modifier) { // TODO will be handle in [REDACTED_TASK_KEY] - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = Modifier - .weight(1f) - .clickable(onClick = state.onRowClick), - verticalAlignment = Alignment.CenterVertically, - ) { - CurrencyIcon( - modifier = Modifier.padding(end = TangemTheme.dimens2.x3), - state = state.tokenIcon, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = state.tokenName, - style = TangemTheme.typography2.bodyMedium16, - color = TangemTheme.colors2.text.neutral.primary, - maxLines = 1, - ) - Text( - text = stringResourceSafe(R.string.markets_portfolio_block_subtitle), - style = TangemTheme.typography2.captionMedium12, - color = TangemTheme.colors2.text.neutral.secondary, - maxLines = 1, - ) - } - Column(horizontalAlignment = Alignment.End) { - Text( - text = state.totalBalance.orMaskWithStars(state.isBalanceHidden).resolveAnnotatedReference(), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = TangemTheme.colors2.text.neutral.primary, - style = TangemTheme.typography2.bodyMedium16, - ) - Text( - text = state.tokenSymbol, - style = TangemTheme.typography2.captionMedium12, - color = TangemTheme.colors2.text.neutral.secondary, - maxLines = 1, - ) - } - } + FloatingCard(modifier = modifier) { + TangemRowContainer(modifier = Modifier.clickableSingle(onClick = state.onRowClick)) { + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + text = state.totalBalance.orMaskWithStars(state.isBalanceHidden).resolveAnnotatedReference(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.bodyMedium16, + ) + + Text( + modifier = Modifier.layoutId(TangemRowLayoutId.START_BOTTOM), + text = stringResourceSafe(R.string.markets_portfolio_block_subtitle), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TangemButton( - modifier = Modifier.padding(start = TangemTheme.dimens2.x3), + modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), + buttonUM = TangemButtonUM( + text = resourceReference(R.string.common_add_funds), + type = TangemButtonType.Secondary, + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_arrow_down_20, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, + ), + iconPosition = TangemButtonIconPosition.Start, + shape = TangemButtonShape.Rounded, + size = TangemButtonSize.X9, + onClick = state.onAddFundsClick, + ), + ) + + TangemButton( + modifier = Modifier + .layoutId(TangemRowLayoutId.TAIL) + .padding(start = TangemTheme.dimens2.x3), buttonUM = TangemButtonUM( type = TangemButtonType.Secondary, tangemIconUM = TangemIconUM.Icon( - iconRes = R.drawable.ic_chevron_24, + iconRes = R.drawable.ic_arrow_expand_24, tintReference = { TangemTheme.colors2.graphic.neutral.primary }, ), shape = TangemButtonShape.Rounded, size = TangemButtonSize.X9, - onClick = state.onAddFundsClick, + onClick = state.onRowClick, ), ) } @@ -156,7 +152,12 @@ private fun ContentBlock(state: PortfolioBlockUM.Content, modifier: Modifier = M @Composable private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier = Modifier) { FloatingCard(modifier = modifier) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier + .padding(TangemTheme.dimens2.x3) + .clickableSingle(onClick = state.onAddClick), + verticalAlignment = Alignment.CenterVertically, + ) { CurrencyIcon(state.tokenIcon) SpacerW(TangemTheme.dimens2.x3) @@ -204,8 +205,7 @@ private fun FloatingCard(modifier: Modifier = Modifier, content: @Composable () .background( color = TangemTheme.colors2.surface.level3, shape = RoundedCornerShape(size = TangemTheme.dimens2.x5), - ) - .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3), + ), ) { content() } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt index eaf7f1665d..d0bf1b73b9 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -17,6 +17,7 @@ import com.arkivanov.decompose.router.stack.ChildStack import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent +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 @@ -130,6 +131,12 @@ private fun EntryContentV2( child.instance.Content( modifier = Modifier .fillMaxSize() + .conditionalCompose( + condition = !isOpenedInBottomSheet, + modifier = { + padding(top = topBarHeight) + }, + ) .hazeSourceTangem(zIndex = 0f, state = hazeState), contentPadding = PaddingValues(top = topBarHeight), bottomSheetState = bottomSheetState, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index 79330be97f..3377a8eb08 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -10,13 +10,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -27,11 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.SpacerH16 -import com.tangem.core.ui.components.SpacerH32 -import com.tangem.core.ui.components.SpacerH4 -import com.tangem.core.ui.components.SpacerW4 +import com.tangem.core.ui.components.* import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.buttons.segmentedbutton.SegmentedButtons @@ -171,7 +161,7 @@ private fun Content( Modifier .onSizeChanged { size -> bottomSpacing = if (size.height > 0) { - with(density) { size.height.toDp() + 16.dp } + with(density) { size.height.toDp() } } else { 0.dp } From 82506a1c9c5992f4bd37b68b28c95eadfde54a40 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Apr 2026 18:02:22 +0400 Subject: [PATCH 130/206] Updated on 2026-08-14 --- .../api/choosetoken}/ChooseTokenBridge.kt | 41 +- .../api/choosetoken}/ChooseTokenComponent.kt | 4 +- .../model/ChooseTokenPortfolioFullBlockUM.kt | 2 +- .../common-features/impl/build.gradle.kts | 3 +- .../choosetoken}/DefaultChooseTokenBridge.kt | 35 +- .../DefaultChooseTokenComponent.kt | 25 +- .../choosetoken}/SettingContextUseCase.kt | 3 +- .../converter/ChooseTokenListItemConverter.kt | 12 +- .../converter/SearchBarToggleTransformer.kt | 4 +- .../SearchBarUpdateQueryTransformer.kt | 4 +- .../impl/choosetoken}/di/ChooseTokenModule.kt | 12 +- .../market/MarketsListBatchFlowManager.kt | 4 +- .../SwapMarketsTokenItemConverter.kt | 4 +- .../market/state/SwapMarketState.kt | 2 +- .../choosetoken}/model/ChooseTokenModel.kt | 73 +-- .../choosetoken}/model/MarketBlockDelegate.kt | 15 +- .../model/PortfolioFullBlockDelegate.kt | 19 +- .../model/PortfolioListBlockDelegate.kt | 18 +- .../impl/choosetoken}/ui/ChooseTokenScreen.kt | 23 +- .../impl/choosetoken}/ui/ChooseTokenUM.kt | 6 +- .../ui}/SwapMarketsListLazyColumn.kt | 6 +- .../ui/SwapSelectTokenPreviewProvider.kt | 125 +++++ .../feature/swap/DefaultSwapComponent.kt | 2 +- .../swap/converters/TokensDataConverter.kt | 81 ---- .../tangem/feature/swap/model/SwapModel.kt | 6 +- .../swap/models/AddToPortfolioRoute.kt | 7 - .../swap/models/SwapSelectTokenStateHolder.kt | 22 - .../feature/swap/ui/SwapSelectTokenScreen.kt | 457 ------------------ .../preview/SwapSelectTokenPreviewProvider.kt | 221 --------- 29 files changed, 239 insertions(+), 997 deletions(-) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api => common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken}/ChooseTokenBridge.kt (67%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api => common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken}/ChooseTokenComponent.kt (68%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api => common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken}/model/ChooseTokenPortfolioFullBlockUM.kt (95%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/DefaultChooseTokenBridge.kt (66%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/DefaultChooseTokenComponent.kt (78%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/SettingContextUseCase.kt (94%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/converter/ChooseTokenListItemConverter.kt (95%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/converter/SearchBarToggleTransformer.kt (70%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/converter/SearchBarUpdateQueryTransformer.kt (70%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/di/ChooseTokenModule.kt (65%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/models => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/market/MarketsListBatchFlowManager.kt (98%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/models => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/market/converter/SwapMarketsTokenItemConverter.kt (98%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/models => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/market/state/SwapMarketState.kt (96%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/model/ChooseTokenModel.kt (56%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/model/MarketBlockDelegate.kt (93%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/model/PortfolioFullBlockDelegate.kt (84%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/model/PortfolioListBlockDelegate.kt (88%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/ui/ChooseTokenScreen.kt (95%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken}/ui/ChooseTokenUM.kt (64%) rename features/{swap/impl/src/main/java/com/tangem/feature/swap/ui/market => common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui}/SwapMarketsListLazyColumn.kt (94%) create mode 100644 features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/SwapSelectTokenPreviewProvider.kt delete mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokensDataConverter.kt delete mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt delete mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt delete mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt delete mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt similarity index 67% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt index 4ac81de2fe..596f8564aa 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenBridge.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt @@ -1,25 +1,19 @@ -package com.tangem.feature.swap.choosetoken.api +package com.tangem.features.commonfeatures.api.choosetoken import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery -import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM -import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup -import com.tangem.feature.swap.presentation.R +import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.features.commonfeatures.api.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -// todo swap move to common-features module -interface ChooseTokenBridge : ChooseTokenBridgeLegacy, ChooseTokenBridgeInternal { +interface ChooseTokenBridge : ChooseTokenBridgeInternal { val onCurrencyChosen: Channel val onClose: Channel @@ -84,33 +78,6 @@ interface ChooseTokenBridgeInternal { } } -// todo swap legacy api, remove -interface ChooseTokenBridgeLegacy : ChooseTokenBridgeInternal { - - val onTokenSelected: Channel - val onNewTokenAdded: Channel> - - val currenciesGroup: Flow - - fun onTokenSelected(result: ChooseTokenResultOld) { - onTokenSelected.trySend(result) - onSearchQuery(SearchQuery.Empty) - } - - fun onNewTokenAdded(addedToken: Pair) { - onNewTokenAdded.trySend(addedToken) - onSearchQuery(SearchQuery.Empty) - } - - fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) -} - -data class ChooseTokenResultOld( - val cryptoCurrencyStatus: CryptoCurrencyStatus, - val account: Account, - val isSearched: Boolean, -) - data class ChooseTokenResult( val currency: CryptoCurrencyStatus, val account: AccountStatus, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenComponent.kt similarity index 68% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenComponent.kt index 36bd0585db..0aa01e30f1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/ChooseTokenComponent.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenComponent.kt @@ -1,9 +1,9 @@ -package com.tangem.feature.swap.choosetoken.api +package com.tangem.features.commonfeatures.api.choosetoken import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent -internal interface ChooseTokenComponent : ComposableContentComponent { +interface ChooseTokenComponent : ComposableContentComponent { data class Params( val bridge: ChooseTokenBridge, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/model/ChooseTokenPortfolioFullBlockUM.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/model/ChooseTokenPortfolioFullBlockUM.kt similarity index 95% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/model/ChooseTokenPortfolioFullBlockUM.kt rename to features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/model/ChooseTokenPortfolioFullBlockUM.kt index 4611fcdaab..97a3274e8b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/model/ChooseTokenPortfolioFullBlockUM.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/model/ChooseTokenPortfolioFullBlockUM.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.api.model +package com.tangem.features.commonfeatures.api.choosetoken.model import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM diff --git a/features/common-features/impl/build.gradle.kts b/features/common-features/impl/build.gradle.kts index 4632310a53..d7d2787421 100644 --- a/features/common-features/impl/build.gradle.kts +++ b/features/common-features/impl/build.gradle.kts @@ -18,8 +18,6 @@ tasks.withType().configureEach { dependencies { /** Api */ implementation(projects.features.commonFeatures.api) - // todo swap delete after move portfolio selector - implementation(projects.features.account.api) implementation(projects.features.wallet.api) implementation(projects.features.tokenRecieve.api) @@ -57,6 +55,7 @@ dependencies { /** Common */ implementation(projects.common.ui) + implementation(projects.common.uiCharts) implementation(projects.common.uiMarkets) implementation(projects.common.routing) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenBridge.kt similarity index 66% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenBridge.kt index 39dfd2fdc1..3037369099 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenBridge.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenBridge.kt @@ -1,20 +1,17 @@ -package com.tangem.feature.swap.choosetoken.impl +package com.tangem.features.commonfeatures.impl.choosetoken import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge.Settings -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery -import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult -import com.tangem.feature.swap.choosetoken.api.ChooseTokenResultOld -import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM -import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY -import com.tangem.feature.swap.choosetoken.impl.model.PortfolioFullBlockDelegate -import com.tangem.feature.swap.choosetoken.impl.model.PortfolioListBlockDelegate -import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge.Settings +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioFullBlockDelegate +import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioListBlockDelegate +import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -31,14 +28,11 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( ) : ChooseTokenBridge { override val onCurrencyChosen: Channel = Channel() - - override val onTokenSelected: Channel = Channel() - override val onNewTokenAdded: Channel> = Channel() override val onClose: Channel = Channel() private val onSearchQuery: Channel = Channel() override val searchQueryState: StateFlow = onSearchQuery.receiveAsFlow() - .debounce(DEBOUNCE_SEARCH_DELAY) + .debounce(ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY) .stateIn(modelScope, SharingStarted.Eagerly, initialValue = SearchQuery.Empty) private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create( @@ -59,9 +53,6 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( override val fullPortfolioBlock: StateFlow get() = portfolioFullBlockDelegate.fullPortfolioBlock - private val _currenciesGroupFlow = MutableStateFlow(null) - override val currenciesGroup: Flow = _currenciesGroupFlow.filterNotNull() - init { portfolioListBlockDelegate.onTokenChosen.receiveAsFlow() .onEach { chooseResult -> onCurrencyChosen(chooseResult) } @@ -86,10 +77,6 @@ internal class DefaultChooseTokenBridge @AssistedInject constructor( onSearchQuery(SearchQuery.Empty) } - override fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) { - _currenciesGroupFlow.update { currenciesGroup } - } - @AssistedFactory interface Factory : ChooseTokenBridge.Factory { override fun create( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenComponent.kt similarity index 78% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenComponent.kt index 750f2de85c..7a55c72de3 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/DefaultChooseTokenComponent.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/DefaultChooseTokenComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.impl +package com.tangem.features.commonfeatures.impl.choosetoken import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -10,16 +10,16 @@ import com.arkivanov.decompose.router.slot.childSlot import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.decompose.navigation.Route import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent -import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenScreen -import com.tangem.feature.swap.models.AddToPortfolioRoute -import com.tangem.feature.swap.ui.SwapSelectTokenScreen +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenScreen import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.serialization.Serializable internal class DefaultChooseTokenComponent @AssistedInject constructor( private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, @@ -39,15 +39,7 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { - val stateOld by model.stateOld.collectAsStateWithLifecycle() val bottomSheet by bottomSheetSlot.subscribeAsState() - stateOld?.let { stateHolder -> - SwapSelectTokenScreen(state = stateHolder, onBack = { model.onBackClicked() }) - bottomSheet.child?.instance?.BottomSheet() - // if old shown we should not show new screen - return - } - val state by model.state.collectAsStateWithLifecycle() ChooseTokenScreen(state = state) bottomSheet.child?.instance?.BottomSheet() @@ -74,4 +66,7 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor( private companion object { const val BOTTOM_SHEET_SLOT_KEY = "choosePortfolioTokenBottomSheetSlot" } -} \ No newline at end of file +} + +@Serializable +internal data object AddToPortfolioRoute : Route \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/SettingContextUseCase.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/SettingContextUseCase.kt similarity index 94% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/SettingContextUseCase.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/SettingContextUseCase.kt index 9eee2c081b..d382260ecd 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/api/SettingContextUseCase.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/SettingContextUseCase.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.api +package com.tangem.features.commonfeatures.impl.choosetoken import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import javax.inject.Inject -// todo swap move to some common module class SettingContextUseCase @Inject constructor( private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt similarity index 95% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt index 277772d1df..004b306913 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/ChooseTokenListItemConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/ChooseTokenListItemConverter.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.impl.converter +package com.tangem.features.commonfeatures.impl.choosetoken.converter import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter import com.tangem.common.ui.account.TokensListPortfolioItemConverter @@ -23,11 +23,11 @@ import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.tokenlist.TokenList -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState -import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData -import com.tangem.feature.swap.choosetoken.impl.model.ClickIntents -import com.tangem.feature.swap.presentation.R +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData +import com.tangem.features.commonfeatures.impl.choosetoken.model.ClickIntents +import com.tangem.features.commonfeatures.impl.R import kotlinx.collections.immutable.toPersistentList internal class ChooseTokenListItemConverter( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarToggleTransformer.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarToggleTransformer.kt similarity index 70% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarToggleTransformer.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarToggleTransformer.kt index 94dc7a09a9..41a7a2a758 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarToggleTransformer.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarToggleTransformer.kt @@ -1,6 +1,6 @@ -package com.tangem.feature.swap.choosetoken.impl.converter +package com.tangem.features.commonfeatures.impl.choosetoken.converter -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM import com.tangem.utils.transformer.Transformer internal class SearchBarToggleTransformer(private val isActive: Boolean) : Transformer { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarUpdateQueryTransformer.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarUpdateQueryTransformer.kt similarity index 70% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarUpdateQueryTransformer.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarUpdateQueryTransformer.kt index 33cf694057..94c6e2d19c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/converter/SearchBarUpdateQueryTransformer.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/converter/SearchBarUpdateQueryTransformer.kt @@ -1,6 +1,6 @@ -package com.tangem.feature.swap.choosetoken.impl.converter +package com.tangem.features.commonfeatures.impl.choosetoken.converter -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM import com.tangem.utils.transformer.Transformer internal class SearchBarUpdateQueryTransformer(private val newQuery: String) : Transformer { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/di/ChooseTokenModule.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/di/ChooseTokenModule.kt similarity index 65% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/di/ChooseTokenModule.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/di/ChooseTokenModule.kt index 48e130b850..e3a3b614b6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/di/ChooseTokenModule.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/di/ChooseTokenModule.kt @@ -1,12 +1,12 @@ -package com.tangem.feature.swap.choosetoken.impl.di +package com.tangem.features.commonfeatures.impl.choosetoken.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge -import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent -import com.tangem.feature.swap.choosetoken.impl.DefaultChooseTokenBridge -import com.tangem.feature.swap.choosetoken.impl.DefaultChooseTokenComponent -import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.commonfeatures.impl.choosetoken.DefaultChooseTokenBridge +import com.tangem.features.commonfeatures.impl.choosetoken.DefaultChooseTokenComponent +import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/MarketsListBatchFlowManager.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt similarity index 98% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/MarketsListBatchFlowManager.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt index 64d320b24a..bff9bfb159 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/MarketsListBatchFlowManager.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/MarketsListBatchFlowManager.kt @@ -1,11 +1,11 @@ -package com.tangem.feature.swap.models.market +package com.tangem.features.commonfeatures.impl.choosetoken.market import com.tangem.common.ui.markets.models.MarketsListItemUM import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.* import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.feature.swap.models.market.converter.SwapMarketsTokenItemConverter +import com.tangem.features.commonfeatures.impl.choosetoken.market.converter.SwapMarketsTokenItemConverter import com.tangem.pagination.Batch import com.tangem.pagination.BatchAction import com.tangem.pagination.PaginationStatus diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/converter/SwapMarketsTokenItemConverter.kt similarity index 98% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/converter/SwapMarketsTokenItemConverter.kt index 0e693eab2d..e42f02c207 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/converter/SwapMarketsTokenItemConverter.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/converter/SwapMarketsTokenItemConverter.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.models.market.converter +package com.tangem.features.commonfeatures.impl.choosetoken.market.converter import com.tangem.common.ui.charts.state.MarketChartData import com.tangem.common.ui.charts.state.MarketChartRawData @@ -16,7 +16,7 @@ import com.tangem.core.ui.format.bigdecimal.percent import com.tangem.core.ui.format.bigdecimal.price import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket -import com.tangem.feature.swap.presentation.R +import com.tangem.features.commonfeatures.impl.R import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toImmutableList import java.math.BigDecimal diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt similarity index 96% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt index e18aad5dd7..dd11a69c73 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/market/state/SwapMarketState.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/market/state/SwapMarketState.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.models.market.state +package com.tangem.features.commonfeatures.impl.choosetoken.market.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt similarity index 56% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt index f03c6bc306..4687184b49 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/ChooseTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.impl.model +package com.tangem.features.commonfeatures.impl.choosetoken.model import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped @@ -6,18 +6,18 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.account.AccountId -import com.tangem.feature.swap.choosetoken.api.* -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState -import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarToggleTransformer -import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarUpdateQueryTransformer -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenFullUM -import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM -import com.tangem.feature.swap.converters.TokensDataConverter -import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.feature.swap.presentation.R +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer +import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM +import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM +import com.tangem.features.commonfeatures.api.R +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.* import javax.inject.Inject @@ -26,7 +26,6 @@ import javax.inject.Inject @ModelScoped internal class ChooseTokenModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, - private val settingContextUseCase: SettingContextUseCase, marketBlockDelegateFactory: MarketBlockDelegate.Factory, paramsContainer: ParamsContainer, ) : Model() { @@ -52,10 +51,6 @@ internal class ChooseTokenModel @Inject constructor( flowOf(null) } - private val expandedAccountsFlow: MutableStateFlow> = MutableStateFlow(emptyMap()) - - val stateOld: StateFlow = combineUIOld() - private val initialState: MutableStateFlow = MutableStateFlow(getInitState()) val state: StateFlow = combine( flow = initialState, @@ -85,8 +80,6 @@ internal class ChooseTokenModel @Inject constructor( addToPortfolioManager.onSuccessAdded.receiveAsFlow() .onEach { addedResult -> val isSearched = ChooseTokenAnalyticsPayload.IsSearched(isSearchingState) - val newToken = addedResult.addedCurrency.currency to isSearched - bridge.onNewTokenAdded(newToken) val chooseTokenResult = ChooseTokenResult( currency = addedResult.addedCurrency, account = addedResult.account, @@ -99,46 +92,6 @@ internal class ChooseTokenModel @Inject constructor( .launchIn(modelScope) } - @Suppress("UnusedPrivateMember") - private fun combineUIOld(): StateFlow = combine( - flow = bridge.currenciesGroup, - flow2 = settingContextUseCase.invoke(), - flow3 = marketsStateFlow, - flow4 = expandedAccountsFlow, - transform = { currenciesGroup, settingContext, marketState, expandedAccounts -> - val isAccountsMode = settingContext.isAccountsMode - val appCurrency = settingContext.appCurrency - val isBalanceHidden = settingContext.isBalanceHidden - - TokensDataConverter( - onSearchEntered = { query -> bridge.onSearchQuery(query) }, - onTokenClick = { account, cryptoCurrencyStatus -> - val result = ChooseTokenResultOld( - account = account, - cryptoCurrencyStatus = cryptoCurrencyStatus, - isSearched = searchQueryState.isSearchingState, - ) - bridge.onTokenSelected(result) - }, - onAccountClick = { account -> - expandedAccountsFlow.update { expandedList -> - val hasSavedAccount = expandedList[account.accountId] - val isExpanded = hasSavedAccount == true - expandedList + (account.accountId to !isExpanded) - } - }, - expandedAccounts = expandedAccounts, - tokensDataState = currenciesGroup, - isBalanceHidden = isBalanceHidden, - isAccountsMode = isAccountsMode, - appCurrency = appCurrency, - marketState = requireNotNull(marketState), - ).transform() - }, - ) - .flowOn(dispatchers.default) - .stateIn(modelScope, SharingStarted.Eagerly, initialValue = null) - fun onBackClicked() { bridge.onClose() } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt similarity index 93% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt index e9c9ff93d4..a13fbe77a5 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/MarketBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/MarketBlockDelegate.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.impl.model +package com.tangem.features.commonfeatures.impl.choosetoken.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate @@ -14,12 +14,12 @@ import com.tangem.domain.markets.toSerializableParam import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState -import com.tangem.feature.swap.models.AddToPortfolioRoute -import com.tangem.feature.swap.models.market.MarketsListBatchFlowManager -import com.tangem.feature.swap.models.market.state.SwapMarketState +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute +import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.Provider import dagger.assisted.Assisted @@ -27,6 +27,9 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.* +import kotlin.collections.filter +import kotlin.collections.map +import kotlin.collections.orEmpty @Suppress("LongParameterList") internal class MarketBlockDelegate @AssistedInject constructor( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioFullBlockDelegate.kt similarity index 84% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioFullBlockDelegate.kt index c19b107c11..8b1670bc5d 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioFullBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioFullBlockDelegate.kt @@ -1,19 +1,20 @@ -package com.tangem.feature.swap.choosetoken.impl.model +package com.tangem.features.commonfeatures.impl.choosetoken.model import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState -import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase -import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM -import com.tangem.feature.swap.choosetoken.api.model.WalletListUM -import com.tangem.feature.swap.choosetoken.api.model.WalletTabUM +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM +import com.tangem.features.commonfeatures.impl.choosetoken.SettingContextUseCase import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -60,8 +61,10 @@ internal class PortfolioFullBlockDelegate @AssistedInject constructor( } private fun buildFlow() = flow { + val walletsFlow = getWalletsUseCase.invokeAsMap() + .map { wallets -> wallets.filterNot { (_, wallet) -> wallet.isLocked } } val fullPortfolioBlockFlow = combine( - flow = getWalletsUseCase.invokeAsMap(), + flow = walletsFlow, flow2 = portfolioListBlockDelegate.portfolioList, flow3 = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(), flow4 = settingContextUseCase.invoke(), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt similarity index 88% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt index ccbbf72f16..f2acbf62c8 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/model/PortfolioListBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.impl.model +package com.tangem.features.commonfeatures.impl.choosetoken.model import com.tangem.common.ui.tokens.TokenConverterParams import com.tangem.domain.account.models.AccountStatusList @@ -11,14 +11,14 @@ 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.wallets.usecase.GetWalletsUseCase -import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState -import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult -import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase -import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData -import com.tangem.feature.swap.choosetoken.impl.converter.ChooseTokenListItemConverter +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult +import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData +import com.tangem.features.commonfeatures.impl.choosetoken.SettingContextUseCase +import com.tangem.features.commonfeatures.impl.choosetoken.converter.ChooseTokenListItemConverter import com.tangem.utils.extensions.mapNotNullValues import dagger.assisted.Assisted import dagger.assisted.AssistedFactory diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt similarity index 95% rename from features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt rename to features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index 5f169d8b78..1db37d2b53 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/choosetoken/impl/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -1,4 +1,4 @@ -package com.tangem.feature.swap.choosetoken.impl.ui +package com.tangem.features.commonfeatures.impl.choosetoken.ui import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -45,14 +45,12 @@ import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.utils.TangemSharedTransitionLayout import com.tangem.core.ui.utils.lazyListItemPosition import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection -import com.tangem.feature.swap.choosetoken.api.model.ChooseTokenPortfolioFullBlockUM -import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData -import com.tangem.feature.swap.choosetoken.api.model.WalletListUM -import com.tangem.feature.swap.choosetoken.api.model.WalletTabUM -import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.feature.swap.presentation.R -import com.tangem.feature.swap.ui.market.swapMarketsListItems -import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider +import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM +import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM +import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM +import com.tangem.features.commonfeatures.impl.R +import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -254,7 +252,8 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { style = TangemTheme.typography.button, ) - if (state.count != null) { + val count = state.count + if (count != null) { Spacer(modifier = Modifier.width(8.dp)) Box( @@ -265,7 +264,7 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { contentAlignment = Alignment.Center, ) { Text( - text = state.count.resolveReference(), + text = count.resolveReference(), color = countTextColor, style = TangemTheme.typography.caption1, ) @@ -483,7 +482,7 @@ private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider Unit, - onAccountClick: (Account) -> Unit, - private val expandedAccounts: Map, - private val onSearchEntered: (String) -> Unit, - private val tokensDataState: CurrenciesGroup, - private val isBalanceHidden: Boolean, - private val isAccountsMode: Boolean, - private val appCurrency: AppCurrency, - private val marketState: SwapMarketState, -) { - - private val accountListItemConverter = AccountTokenItemConverter( - appCurrency = appCurrency, - unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), - onTokenItemClick = onTokenClick, - onAccountItemClick = onAccountClick, - expandedAccounts = expandedAccounts, - ) - - fun transform(): SwapSelectTokenStateHolder { - val accountList = tokensDataState.accountCurrencyList - return SwapSelectTokenStateHolder( - tokensListData = if (isAccountsMode) { - val portfolioList = accountListItemConverter.convertList(accountList).toPersistentList() - val totalTokensCount = portfolioList.sumOf { it.tokens.size } - if (totalTokensCount > 0) { - TokenListUMData.AccountList( - tokensList = portfolioList, - totalTokensCount = totalTokensCount, - ) - } else { - TokenListUMData.EmptyList - } - } else { - val tokensList = accountList.flatMap { (_, currencyList) -> - currencyList.asSequence().map { accountSwapCurrency -> - accountListItemConverter.createAvailableItemConverter(accountSwapCurrency.account) - .convert(accountSwapCurrency.cryptoCurrencyStatus) - }.map(TokensListItemUM::Token).toPersistentList() - }.toPersistentList() - - if (tokensList.isNotEmpty()) { - TokenListUMData.TokenList( - tokensList = persistentListOf( - TokensListItemUM.GroupTitle( - id = "available_tokens_title", - text = resourceReference(R.string.exchange_tokens_available_tokens_header), - ), - ) + tokensList, - totalTokensCount = tokensList.size, - ) - } else { - TokenListUMData.EmptyList - } - }, - marketsState = marketState, - onSearchEntered = onSearchEntered, - isBalanceHidden = isBalanceHidden, - isAfterSearch = tokensDataState.isAfterSearch, - ) - } -} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index c1e58f73df..89c11aa06a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -69,9 +69,9 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.analytics.SwapQuotePerformanceTracker -import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload -import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge -import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.domain.AllowPermissionsHandler diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt deleted file mode 100644 index afebcd52cf..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.feature.swap.models - -import com.tangem.core.decompose.navigation.Route -import kotlinx.serialization.Serializable - -@Serializable -internal data object AddToPortfolioRoute : Route \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt deleted file mode 100644 index 5582d69682..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapSelectTokenStateHolder.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.feature.swap.models - -import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData -import com.tangem.feature.swap.models.market.state.SwapMarketState - -internal data class SwapSelectTokenStateHolder( - val marketsState: SwapMarketState, - val tokensListData: TokenListUMData, - val isBalanceHidden: Boolean, - val isAfterSearch: Boolean, - val onSearchEntered: (String) -> Unit, -) - -internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean - get() = - tokensListData.tokensList.isEmpty() && isAfterSearch && - marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading - -internal val SwapSelectTokenStateHolder.isEmptyState: Boolean - get() = - tokensListData.tokensList.isEmpty() && !isAfterSearch && - marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt deleted file mode 100644 index 4ea3cff4f0..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSelectTokenScreen.kt +++ /dev/null @@ -1,457 +0,0 @@ -package com.tangem.feature.swap.ui - -import android.content.res.Configuration -import androidx.activity.compose.BackHandler -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.* -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.components.appbar.ExpandableSearchView -import com.tangem.core.ui.components.list.InfiniteListHandler -import com.tangem.core.ui.components.tokenlist.PortfolioListItem -import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem -import com.tangem.core.ui.components.tokenlist.TokenListItem -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM -import com.tangem.core.ui.decorations.roundedShapeItemDecoration -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.BuyTokenScreenTestTags -import com.tangem.core.ui.test.MainScreenTestTags -import com.tangem.core.ui.utils.lazyListItemPosition -import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData -import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.isEmptyState -import com.tangem.feature.swap.models.isNotFoundState -import com.tangem.feature.swap.models.market.state.SwapMarketState -import com.tangem.feature.swap.presentation.R -import com.tangem.feature.swap.ui.market.swapMarketsListItems -import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider -import kotlinx.collections.immutable.ImmutableList - -private const val LOAD_MORE_BUFFER = 25 - -@Composable -internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) { - BackHandler(onBack = onBack) - - Scaffold( - modifier = Modifier - .systemBarsPadding() - .background(color = TangemTheme.colors.background.secondary), - content = { padding -> - val modifier = Modifier.padding(padding) - when { - state.isNotFoundState -> TokensNotFound(modifier) - state.isEmptyState -> EmptyTokensList(modifier) - state.marketsState != null -> ListOfTokensWithMarkets( - state = state, - marketsState = state.marketsState, - modifier = modifier, - ) - else -> ListOfTokens(state = state, modifier = modifier) - } - }, - topBar = { - ExpandableSearchView( - title = stringResourceSafe(R.string.common_choose_token), - onBackClick = onBack, - placeholderSearchText = stringResourceSafe(id = R.string.common_search_tokens), - onSearchChange = state.onSearchEntered, - onSearchDisplayClose = { state.onSearchEntered("") }, - subtitle = stringResourceSafe(id = R.string.express_exchange_token_list_subtitle), - ) - }, - ) -} - -@Composable -private fun EmptyTokensList(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .background(TangemTheme.colors.background.secondary) - .fillMaxSize(), - ) { - Column(modifier = Modifier.align(Alignment.Center)) { - Image( - modifier = Modifier - .size(TangemTheme.dimens.size64) - .align(Alignment.CenterHorizontally), - painter = painterResource(id = R.drawable.ic_no_token_44), - colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), - contentDescription = null, - ) - Text( - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing16) - .padding(horizontal = TangemTheme.dimens.spacing30) - .align(Alignment.CenterHorizontally), - text = stringResourceSafe(id = R.string.exchange_tokens_empty_tokens), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - ) - } - } -} - -@Composable -private fun TokensNotFound(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .background(TangemTheme.colors.background.secondary) - .fillMaxSize(), - ) { - Text( - modifier = Modifier - .padding(top = TangemTheme.dimens.spacing32) - .padding(horizontal = TangemTheme.dimens.spacing30) - .align(Alignment.TopCenter), - text = stringResourceSafe(id = R.string.express_token_list_empty_search), - style = TangemTheme.typography.subtitle1, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.Center, - ) - } -} - -@Composable -private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) { - val screenBackgroundColor = TangemTheme.colors.background.secondary - - LazyColumn( - modifier = modifier - .background(color = screenBackgroundColor) - .fillMaxSize() - .imePadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - tokensListItems( - tokensListData = state.tokensListData, - isBalanceHidden = state.isBalanceHidden, - ) - } -} - -@Composable -private fun ListOfTokensWithMarkets( - state: SwapSelectTokenStateHolder, - marketsState: SwapMarketState, - modifier: Modifier = Modifier, -) { - val screenBackgroundColor = TangemTheme.colors.background.secondary - val lazyListState = rememberLazyListState() - - LazyColumn( - modifier = modifier - .background(color = screenBackgroundColor) - .fillMaxSize() - .imePadding(), - horizontalAlignment = Alignment.CenterHorizontally, - state = lazyListState, - ) { - if (state.tokensListData !is TokenListUMData.EmptyList) { - assetsTitle(count = state.tokensListData.totalTokensCount, showCount = marketsState.shouldAssetsCount) - } - - tokensListItems( - tokensListData = state.tokensListData, - isBalanceHidden = state.isBalanceHidden, - ) - - item("spacer_before_markets") { - SpacerH(32.dp) - } - - swapMarketsListItems(marketsState) - } - - (marketsState as? SwapMarketState.Content)?.let { content -> - VisibleItemsTracker( - lazyListState = lazyListState, - marketState = content, - ) - - InfiniteListHandler( - listState = lazyListState, - buffer = LOAD_MORE_BUFFER, - triggerLoadMoreCheckOnItemsCountChange = true, - onLoadMore = remember(content) { - { - content.loadMore() - true - } - }, - ) - } -} - -@Composable -private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) { - val visibleItems by remember { - derivedStateOf { - lazyListState.layoutInfo.visibleItemsInfo - .mapNotNull { itemInfo -> - marketState.items.find { it.getComposeKey() == itemInfo.key }?.id - } - } - } - - LaunchedEffect(visibleItems) { - marketState.visibleIdsChanged(visibleItems) - } -} - -private fun LazyListScope.assetsTitle(count: Int, showCount: Boolean) { - item(key = "assets_title") { - Text( - text = buildAnnotatedString { - append(stringResourceSafe(R.string.swap_your_assets_title)) - if (showCount) { - withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { - append(" $count") - } - } - }, - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .fillMaxWidth() - .padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - top = TangemTheme.dimens.spacing12, - ), - ) - } -} - -private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) { - when (tokensListData) { - is TokenListUMData.AccountList -> { - tokensListData.tokensList.forEachIndexed { index, item -> - portfolioTokensList( - portfolio = item, - isBalanceHidden = isBalanceHidden, - portfolioIndex = index, - modifier = Modifier, - ) - } - } - is TokenListUMData.TokenList -> { - tokensList( - items = tokensListData.tokensList, - isBalanceHidden = isBalanceHidden, - ) - } - TokenListUMData.EmptyList -> Unit - } -} - -private fun LazyListScope.tokensList(items: ImmutableList, isBalanceHidden: Boolean) { - itemsIndexed( - items = items, - key = { _, item -> item.id }, - contentType = { _, item -> item::class.java }, - itemContent = { index, item -> - TokenListItem( - state = item, - isBalanceHidden = isBalanceHidden, - modifier = Modifier - .roundedShapeItemDecoration( - currentIndex = index, - lastIndex = items.lastIndex, - backgroundColor = TangemTheme.colors.background.primary, - ) - .testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) - .semantics { lazyListItemPosition = index }, - ) - }, - ) -} - -internal fun LazyListScope.portfolioTokensList( - portfolio: TokensListItemUM.Portfolio, - modifier: Modifier, - portfolioIndex: Int, - isBalanceHidden: Boolean, -) { - val tokens = portfolio.tokens - val isExpanded = portfolio.isExpanded - val lastIndex = tokens.lastIndex.inc() - - portfolioItem( - portfolio = portfolio, - modifier = modifier, - portfolioIndex = portfolioIndex, - isBalanceHidden = isBalanceHidden, - ) - itemsIndexed( - items = tokens, - key = { _, item -> item.id.toString() + "-portfolio-${portfolio.id}" }, - contentType = { _, item -> item::class.java }, - itemContent = { tokenIndex, token -> - val indexWithHeader = tokenIndex.inc() - SlideInItemVisibility( - currentIndex = tokenIndex, - lastIndex = lastIndex, - modifier = modifier - .testModifier(indexWithHeader) - .animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null) - .roundedShapeItemDecoration( - radius = TangemTheme.dimens.radius14, - currentIndex = indexWithHeader, - lastIndex = lastIndex, - backgroundColor = TangemTheme.colors.background.primary, - ), - visible = isExpanded, - ) { - val innerModifier = if (indexWithHeader == lastIndex) Modifier.padding(bottom = 8.dp) else Modifier - PortfolioTokensListItem( - state = token, - isBalanceHidden = isBalanceHidden, - modifier = innerModifier, - ) - } - }, - ) -} - -@Suppress("MagicNumber") -private fun LazyListScope.portfolioItem( - portfolio: TokensListItemUM.Portfolio, - modifier: Modifier, - portfolioIndex: Int, - isBalanceHidden: Boolean, -) { - val tokens = portfolio.tokens - val isExpanded = portfolio.isExpanded - val lastIndex = when { - isExpanded && tokens.isEmpty() -> 1 - isExpanded -> tokens.lastIndex.inc() - else -> 0 - } - - item( - key = "account-${portfolio.id}", - contentType = "account-content", - ) { - // Snap immediately on expand; on collapse, hold until all child items finish - // their shrink animation, then snap to fully-rounded shape. - val effectiveLastIndex by animateIntAsState( - targetValue = lastIndex, - animationSpec = if (lastIndex != 0) { - snap() - } else { - snap(delayMillis = minOf(50 * tokens.lastIndex, 250) + 150) - }, - label = "lastIndex", - ) - - PortfolioListItem( - state = portfolio, - isBalanceHidden = isBalanceHidden, - modifier = modifier - .testModifier(portfolioIndex) - .roundedShapeItemDecoration( - currentIndex = 0, - radius = TangemTheme.dimens.radius14, - lastIndex = effectiveLastIndex, - backgroundColor = TangemTheme.colors.background.primary, - ), - ) - } -} - -private fun Modifier.testModifier(index: Int): Modifier = this - .testTag(MainScreenTestTags.TOKEN_LIST_ITEM) - .semantics { lazyListItemPosition = index } - -@Suppress("MagicNumber") -@Composable -internal fun SlideInItemVisibility( - visible: Boolean, - currentIndex: Int, - lastIndex: Int, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - val maxDelay = 250 - val delayEnter = minOf(50 * currentIndex, maxDelay) - val delayExit = minOf(50 * (lastIndex - currentIndex - 1), maxDelay) - - AnimatedVisibility( - modifier = modifier, - visible = visible, - enter = expandVertically( - tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing), - expandFrom = Alignment.Top, - ) + fadeIn(tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing)), - exit = shrinkVertically( - tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing), - shrinkTowards = Alignment.Top, - ) + fadeOut(tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing)), - ) { - content() - } -} - -// region Preview -@Composable -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun SwapSelectTokenScreen_Preview( - @PreviewParameter(SwapSelectTokenScreenPreviewProvider::class) params: SwapSelectTokenStateHolder, -) { - TangemThemePreview { - SwapSelectTokenScreen( - state = params, - onBack = {}, - ) - } -} - -private class SwapSelectTokenScreenPreviewProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - // Content state with tokens and markets - SwapSelectTokenPreviewProvider.defaultState, - // Empty state - SwapSelectTokenStateHolder( - tokensListData = TokenListUMData.EmptyList, - marketsState = SwapMarketState.DefaultLoading, - isAfterSearch = false, - isBalanceHidden = false, - onSearchEntered = {}, - ), - // Not found state - SwapSelectTokenStateHolder( - tokensListData = TokenListUMData.EmptyList, - marketsState = SwapMarketState.SearchLoading, - isAfterSearch = true, - isBalanceHidden = false, - onSearchEntered = {}, - ), - ) -} -// endregion \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt deleted file mode 100644 index 94da0ebd00..0000000000 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapSelectTokenPreviewProvider.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.tangem.feature.swap.ui.preview - -import com.tangem.common.ui.charts.state.MarketChartRawData -import com.tangem.common.ui.markets.models.MarketsListItemUM -import com.tangem.core.ui.R -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.core.ui.components.token.AccountItemPreviewData -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM -import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.feature.swap.choosetoken.api.model.TokenListUMData -import com.tangem.feature.swap.models.SwapSelectTokenStateHolder -import com.tangem.feature.swap.models.market.state.SwapMarketState -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.collections.immutable.toPersistentList -import java.math.BigDecimal - -internal object SwapSelectTokenPreviewProvider { - - private const val CHART_VALUE_1 = 0.4 - private const val CHART_VALUE_2 = 0.2 - private const val CHART_VALUE_3 = 0.1 - private const val CHART_VALUE_4 = 2.0 - private const val CHART_VALUE_5 = 5.0 - private const val CHART_VALUE_6 = 3.0 - private const val TOTAL_ITEMS = 322 - - private val PREVIEW_CHART_DATA = MarketChartRawData( - y = persistentListOf( - CHART_VALUE_1, - CHART_VALUE_2, - CHART_VALUE_1, - CHART_VALUE_3, - CHART_VALUE_1, - CHART_VALUE_4, - CHART_VALUE_5, - CHART_VALUE_3, - CHART_VALUE_4, - CHART_VALUE_4, - CHART_VALUE_6, - ), - ) - - private val tokenItemState = TokenItemState.Content( - id = "1", - iconState = CurrencyIconState.Locked, - titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "12 368,14 \$"), - subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "0,35853044 BTC"), - subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( - price = "34 496,75 \$", - priceChangePercent = "0,43 %", - type = PriceChangeType.DOWN, - ), - onItemClick = {}, - onItemLongClick = {}, - ) - - private val textContentTokensState = persistentListOf( - TokensListItemUM.GroupTitle(id = 111, text = stringReference("Network Bitcoin")), - TokensListItemUM.Token(state = tokenItemState), - TokensListItemUM.GroupTitle(id = 222, text = stringReference("Network Ethereum")), - TokensListItemUM.Token( - state = tokenItemState.copy( - id = "2", - titleState = TokenItemState.TitleState.Content(text = stringReference("Ethereum")), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "3 340,79 \$"), - subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "1,856660295 ETH"), - subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( - price = "1 799,41 \$", - priceChangePercent = "5,16 %", - type = PriceChangeType.UP, - ), - ), - ), - TokensListItemUM.Token( - state = TokenItemState.Unreachable( - id = "3", - iconState = CurrencyIconState.Locked, - titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")), - onItemClick = {}, - onItemLongClick = {}, - ), - ), - TokensListItemUM.Token( - state = tokenItemState.copy( - id = "4", - titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Shiba Inu")), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "48,64 \$"), - subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "6 200 220,00 SHIB"), - subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( - price = "0.01 \$", - priceChangePercent = "1,34 %", - type = PriceChangeType.DOWN, - ), - ), - ), - ) - - private val marketState = SwapMarketState.Content( - items = createPreviewMarketItems(), - loadMore = { }, - onItemClick = { }, - visibleIdsChanged = { }, - total = TOTAL_ITEMS, - marketsTitle = TextReference.Res(R.string.feed_trending_now), - shouldAssetsCount = false, - ) - - val defaultState = SwapSelectTokenStateHolder( - tokensListData = TokenListUMData.AccountList( - tokensList = persistentListOf( - TokensListItemUM.Portfolio( - content = PortfolioItemContentUM.Tokens( - tokens = textContentTokensState.filterIsInstance() - .toPersistentList(), - ), - isExpanded = false, - isCollapsable = true, - tokenItemUM = AccountItemPreviewData.accountItem - .copy(iconState = AccountItemPreviewData.accountLetterIcon), - ), - TokensListItemUM.Portfolio( - content = PortfolioItemContentUM.Tokens( - tokens = textContentTokensState.filterIsInstance() - .toPersistentList(), - ), - isExpanded = true, - isCollapsable = true, - tokenItemUM = AccountItemPreviewData.accountItem, - ), - ), - totalTokensCount = TOTAL_ITEMS, - ), - isAfterSearch = false, - isBalanceHidden = false, - onSearchEntered = {}, - marketsState = marketState, - ) - - private fun createPreviewMarketItems() = listOf( - createMarketItem( - id = "1", - iconUrl = "", - ratingPosition = "10", - marketCap = "$6.233 B", - trendType = PriceChangeType.UP, - chartData = PREVIEW_CHART_DATA, - ), - createMarketItem( - id = "2", - ratingPosition = "10", - marketCap = "$6.233 B", - trendType = PriceChangeType.NEUTRAL, - chartData = null, - ), - createMarketItem( - id = "3", - name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin", - ratingPosition = "10", - marketCap = "$6.23348172384781234 B", - trendType = PriceChangeType.DOWN, - chartData = PREVIEW_CHART_DATA, - ), - createMarketItem( - id = "4", - ratingPosition = "10", - marketCap = null, - trendType = PriceChangeType.UP, - chartData = PREVIEW_CHART_DATA, - ), - createMarketItem( - id = "5", - ratingPosition = null, - marketCap = "$6.233 B", - trendType = PriceChangeType.UP, - chartData = PREVIEW_CHART_DATA, - ), - createMarketItem( - id = "6", - ratingPosition = null, - marketCap = null, - trendType = PriceChangeType.UP, - chartData = PREVIEW_CHART_DATA, - ), - ).toImmutableList() - - private fun createMarketItem( - id: String, - name: String = "Bitcoin", - iconUrl: String? = null, - ratingPosition: String?, - marketCap: String?, - trendType: PriceChangeType, - chartData: MarketChartRawData?, - ) = MarketsListItemUM( - id = CryptoCurrency.RawID(id), - name = name, - currencySymbol = "BTC", - iconUrl = iconUrl, - ratingPosition = ratingPosition, - marketCap = marketCap, - price = MarketsListItemUM.Price( - text = "31 285.72$", - annotated = stringReference("31 285.72$"), - fiatPrice = BigDecimal("123123"), - ), - trendPercentText = "12.43%", - trendType = trendType, - chartData = chartData, - isUnder100kMarketCap = false, - stakingRate = stringReference("APY 12.34%"), - updateTimestamp = 0, - ) -} \ No newline at end of file From 01b51acd9374632d028fd4cf49be4c6798c22324 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Apr 2026 17:05:01 +0300 Subject: [PATCH 131/206] Updated on 2026-08-14 --- .../com/tangem/scenarios/SendScenarios.kt | 9 +- .../com/tangem/scenarios/SwapScenarios.kt | 9 ++ .../screens/SwapChooseTokenPageObject.kt | 60 -------------- .../screens/SwapSelectTokenPageObject.kt | 8 +- .../com/tangem/screens/SwapTokenPageObject.kt | 15 +++- .../tests/send/sendViaSwap/SendViaSwapTest.kt | 36 ++++++-- .../tangem/tests/swap/SearchAndSwapTest.kt | 11 +-- .../tests/swap/SwapChooseTokenScreenTest.kt | 37 ++++----- .../tests/swap/SwapSelectTokenScreenTest.kt | 21 +---- .../tangem/tests/swap/SwapTokenScreenTest.kt | 83 ++++++++++++++----- .../tests/swap/SwapTokenScreenWarningsTest.kt | 32 +++++++ .../tap/domain/sdk/mocks/MockProvider.kt | 1 + .../Wallet2WithDerivationsMockContent.kt | 59 +++++++++++++ 13 files changed, 238 insertions(+), 143 deletions(-) delete mode 100644 app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt create mode 100644 app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithDerivationsMockContent.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt index 343231d121..5277c76ba8 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SendScenarios.kt @@ -10,9 +10,14 @@ import com.tangem.common.extensions.assertIsDimmed import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.setWireMockScenarioState import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.MockContent import io.qameta.allure.kotlin.Allure.step -fun BaseTestCase.openSendScreen(tokenName: String, mockState: String = "") { +fun BaseTestCase.openSendScreen( + tokenName: String, + mockState: String = "", + mockContent: MockContent? = null, +) { val scenarioState = mockState.ifEmpty { tokenName } step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$scenarioState'") { setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState) @@ -21,7 +26,7 @@ fun BaseTestCase.openSendScreen(tokenName: String, mockState: String = "") { setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState) } step("Open 'Main Screen'") { - openMainScreen() + openMainScreen(mockContent = mockContent) } step("Synchronize addresses") { synchronizeAddresses() diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index 358b29b5b3..43fdc8264d 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -297,6 +297,15 @@ fun BaseTestCase.checkSwapWarning( } } +fun BaseTestCase.chooseReceiveToken(tokenName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Click on token with name '$tokenName'") { + onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() } + } +} + sealed class SwapEntryPoint { object MainScreen : SwapEntryPoint() object TokenDetails : SwapEntryPoint() diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt deleted file mode 100644 index 77b3e18ad1..0000000000 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapChooseTokenPageObject.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tangem.screens - -import androidx.compose.ui.test.SemanticsNodeInteractionsProvider -import com.tangem.common.BaseTestCase -import com.tangem.core.ui.R -import com.tangem.core.ui.test.AppBarWithSearchTestTags -import com.tangem.core.ui.test.BuyTokenScreenTestTags -import com.tangem.core.ui.test.MarketsTestTags -import io.github.kakaocup.compose.node.element.ComposeScreen -import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen -import io.github.kakaocup.compose.node.element.KNode -import io.github.kakaocup.kakao.common.utilities.getResourceString -import androidx.compose.ui.test.hasText as withText - -class SwapChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : - ComposeScreen(semanticsProvider = semanticsProvider) { - - val title: KNode = child { - hasText(getResourceString(R.string.common_choose_token)) - } - - val myTokensTitle: KNode = child { - hasText(getResourceString(R.string.exchange_tokens_available_tokens_header)) - } - - val searchIcon: KNode = child { - hasTestTag(AppBarWithSearchTestTags.SEARCH_ICON) - } - - val searchTextField: KNode = child { - hasTestTag(AppBarWithSearchTestTags.TEXT_FIELD) - } - - val noTokensFoundText: KNode = child { - hasText(getResourceString(R.string.express_token_list_empty_search)) - } - - fun tokenWithTitle(tokenTitle: String, availableForSwap: Boolean = true): KNode = child { - hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) - hasAnyDescendant(withText(tokenTitle)) - if (!availableForSwap) { - hasAnyDescendant( - withText( - getResourceString(R.string.tokens_list_unavailable_to_swap_source_header) - ) - ) - } - useUnmergedTree = true - } - - fun marketsTokenWithTitle(title: String): KNode { - return child { - hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) - hasText(title) - } - } -} - -internal fun BaseTestCase.onSwapChooseTokenScreen(function: SwapChooseTokenPageObject.() -> Unit) = - onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt index b07573e5da..56075f5436 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapSelectTokenPageObject.kt @@ -29,8 +29,8 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv } val youSwapBlock: KNode = child { - hasTestTag(SwapSelectTokenScreenTestTags.YOU_SWAP_BLOCK) - hasAnyDescendant(withText(getResourceString(R.string.action_buttons_you_want_to_swap))) + hasTestTag(SwapTokenScreenTestTags.SWAP_CARD) + hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title))) useUnmergedTree = true } @@ -40,8 +40,8 @@ class SwapSelectTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProv } val youReceiveBlock: KNode = child { - hasTestTag(SwapSelectTokenScreenTestTags.YOU_SWAP_BLOCK) - hasAnyDescendant(withText(getResourceString(R.string.action_buttons_you_want_to_receive))) + hasTestTag(SwapTokenScreenTestTags.RECEIVE_CARD) + hasAnyDescendant(withText(getResourceString(R.string.swapping_to_title))) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index e313cac282..73c09bf564 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -174,8 +174,16 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasTestTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT) } - val selectTokenIcon: KNode = child { + val swapSelectTokenIcon: KNode = child { + hasAnyAncestor(withTestTag(SwapTokenScreenTestTags.SWAP_CARD)) hasTestTag(SwapTokenScreenTestTags.SELECT_TOKEN_ICON) + useUnmergedTree = true + } + + val receiveSelectTokenIcon: KNode = child { + hasAnyAncestor(withTestTag(SwapTokenScreenTestTags.RECEIVE_CARD)) + hasTestTag(SwapTokenScreenTestTags.SELECT_TOKEN_ICON) + useUnmergedTree = true } fun swapTokenSymbol(symbol: String): KNode = child { @@ -191,6 +199,11 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(symbol) useUnmergedTree = true } + + val chooseTokenButton: KNode = child { + hasText(getResourceString(R.string.common_choose_token)) + useUnmergedTree = true + } } internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt index 1280e102da..81e533d007 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/SendViaSwapTest.kt @@ -6,15 +6,16 @@ import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.POLYGON_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.SOLANA_RECIPIENT_ADDRESS -import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG -import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.extractText +import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.scenarios.* import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent import dagger.hilt.android.testing.HiltAndroidTest import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.AllureId @@ -34,6 +35,7 @@ class SendViaSwapTest : BaseTestCase() { val bitcoinBalanceScenarioState = "Balance" val assetsScenarioName = "express_api_assets" val assetsScenarioState = "BitcoinExchangeEnabled" + val userTokensScenarioState = "Wallet2" val warningTitle = getResourceString(R.string.express_swap_not_supported_title, stellar) val warningMessage = getResourceString(R.string.express_swap_not_supported_text) @@ -41,6 +43,7 @@ class SendViaSwapTest : BaseTestCase() { additionalAfterSection = { resetWireMockScenarioState(bitcoinBalanceScenarioName) resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) } ).run { @@ -50,9 +53,11 @@ class SendViaSwapTest : BaseTestCase() { step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") { setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState) } - + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensScenarioState) + } step("Open 'Send' screen") { - openSendScreen(tokenName) + openSendScreen(tokenName, mockContent = Wallet2WithDerivationsMockContent) } step("Click on 'Swap to another token button'") { onSendScreen { swapToAnotherTokenButton.performClick() } @@ -93,11 +98,13 @@ class SendViaSwapTest : BaseTestCase() { val bitcoinBalanceScenarioState = "Balance" val assetsScenarioName = "express_api_assets" val assetsScenarioState = "BitcoinExchangeEnabled" + val userTokensScenarioState = "Wallet2" setupHooks( additionalAfterSection = { resetWireMockScenarioState(bitcoinBalanceScenarioName) resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) } ).run { @@ -107,9 +114,12 @@ class SendViaSwapTest : BaseTestCase() { step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") { setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState) } + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensScenarioState) + } step("Open 'Send' screen") { - openSendScreen(tokenName) + openSendScreen(tokenName, mockContent = Wallet2WithDerivationsMockContent) } step("Type '$firstInputAmount' in text field") { onSendScreen { amountInputTextField.performTextInput(firstInputAmount) } @@ -178,10 +188,13 @@ class SendViaSwapTest : BaseTestCase() { step("Assert provider name is '$regularProviderName'") { onSendConfirmScreen { providerName.assertTextContains(regularProviderName) } } - step("Click on fee selector icon") { - onSendConfirmScreen { feeSelectorIcon.performClick() } + step("Open fee selector bottom sheet via click on fee selector icon") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeSelectorIcon.performClick() } + onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).assertIsDisplayed() } + } } - step("Click on '$fastSelectorItem' selector item is displayed") { + step("Click on '$fastSelectorItem' selector item") { onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastSelectorItem).performClick() } } step("Capture fast fee value") { @@ -232,6 +245,7 @@ class SendViaSwapTest : BaseTestCase() { val bitcoinBalanceScenarioState = "Balance" val assetsScenarioName = "express_api_assets" val assetsScenarioState = "BitcoinExchangeEnabled" + val userTokensScenarioState = "Wallet2" val dialogTitle = getResourceString(R.string.send_with_swap_change_token_alert_title) val dialogMessage = getResourceString(R.string.send_with_swap_change_token_alert_message) val addressHint = getResourceString(R.string.send_enter_address_field_ens) @@ -240,6 +254,7 @@ class SendViaSwapTest : BaseTestCase() { additionalAfterSection = { resetWireMockScenarioState(bitcoinBalanceScenarioName) resetWireMockScenarioState(assetsScenarioName) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) } ).run { @@ -249,9 +264,12 @@ class SendViaSwapTest : BaseTestCase() { step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") { setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState) } + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensScenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensScenarioState) + } step("Open 'Send' screen") { - openSendScreen(tokenName) + openSendScreen(tokenName, mockContent = Wallet2WithDerivationsMockContent) } step("Type '$inputAmount' in text field") { onSendScreen { amountInputTextField.performTextInput(inputAmount) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt index e1e79b96ad..5dba4d5d87 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SearchAndSwapTest.kt @@ -238,20 +238,17 @@ class SearchAndSwapTest : BaseTestCase() { onSwapTokenScreen { replaceTokensButton.performClick() } } step("Click on 'Select token' icon") { - onSwapTokenScreen { selectTokenIcon.performClick() } + onSwapTokenScreen { swapSelectTokenIcon.performClick() } } step("Click on 'Search' icon") { - onSwapChooseTokenScreen { searchIcon.performClick() } - } - step("Click on 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performClick() } + onSwapSelectTokenScreen { searchBarIcon.performClick() } } step("Type '$swapTokenSymbol' in 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performTextInputInChunks(swapTokenSymbol) } + onSwapSelectTokenScreen { searchBarBlock.performTextInputInChunks(swapTokenSymbol) } } step("Click on token with name: '$swapTokenName'") { flakySafely(WAIT_UNTIL_TIMEOUT) { - onSwapChooseTokenScreen { marketsTokenWithTitle(swapTokenName).performClick() } + onSwapSelectTokenScreen { marketsTokenWithName(swapTokenName).performClick() } } } step("Click on 'Add' button") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt index f84fdea929..def2f12334 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt @@ -4,7 +4,7 @@ import com.tangem.common.BaseTestCase import com.tangem.common.annotations.ApiEnv import com.tangem.common.annotations.ApiEnvConfig import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO -import com.tangem.common.extensions.* +import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.datasource.api.common.config.ApiConfig @@ -74,22 +74,22 @@ class SwapChooseTokenScreenTest : BaseTestCase() { } } step("Click on 'Select token' icon") { - onSwapTokenScreen { selectTokenIcon.performClick() } + onSwapTokenScreen { swapSelectTokenIcon.performClick() } } step("Assert '$ethereum' is displayed") { - onSwapChooseTokenScreen { tokenWithTitle(ethereum).assertIsDisplayed() } + onSwapSelectTokenScreen { tokenWithName(ethereum).assertIsDisplayed() } } step("Assert '$polExMatic' is displayed") { - onSwapChooseTokenScreen { tokenWithTitle(polExMatic).assertIsDisplayed() } + onSwapSelectTokenScreen { tokenWithName(polExMatic).assertIsDisplayed() } } step("Assert '$bitcoin' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(bitcoin).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(bitcoin).assertIsNotDisplayed() } } step("Assert '$jesusCoin' is displayed and unavailable for swap") { - onSwapChooseTokenScreen { tokenWithTitle(tokenTitle = jesusCoin).assertIsDisplayed() } + onSwapSelectTokenScreen { tokenWithName(jesusCoin).assertIsDisplayed() } } step("Assert custom token without backend id '$salam' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(salam).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(salam).assertIsNotDisplayed() } } } } @@ -136,38 +136,35 @@ class SwapChooseTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Click on 'Select token' icon") { - onSwapTokenScreen { selectTokenIcon.performClick() } + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } } step("Click on 'Search' icon") { - onSwapChooseTokenScreen { searchIcon.performClick() } - } - step("Click on 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performClick() } + onSwapSelectTokenScreen { searchBarIcon.performClick() } } step("Type invalid search text: '$invalidSearchText' in 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performTextReplacement(invalidSearchText) } + onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(invalidSearchText) } } step("Assert '$ethereum' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(ethereum).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(ethereum).assertIsNotDisplayed() } } step("Assert '$polExMatic' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(polExMatic).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(polExMatic).assertIsNotDisplayed() } } step("Press 'Delete' button") { device.uiDevice.pressDelete() } step("Type valid search text: '$validSearchText' in 'Search' text field") { - onSwapChooseTokenScreen { searchTextField.performTextReplacement(validSearchText) } + onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(validSearchText) } } step("Assert '$ethereum' is not displayed") { - onSwapChooseTokenScreen { tokenWithTitle(ethereum).assertIsNotDisplayed() } + onSwapSelectTokenScreen { tokenWithName(ethereum).assertIsNotDisplayed() } } step("Assert '$polExMatic' is displayed") { - onSwapChooseTokenScreen { tokenWithTitle(polExMatic).assertIsDisplayed() } + onSwapSelectTokenScreen { tokenWithName(polExMatic).assertIsDisplayed() } } step("Select new receive token: $polExMatic") { - onSwapChooseTokenScreen { tokenWithTitle(polExMatic).performClick() } + onSwapSelectTokenScreen { tokenWithName(polExMatic).performClick() } } step("Assert new receive token symbol: '$polExMaticSymbol' is displayed") { onSwapTokenScreen { receiveTokenSymbol(polExMaticSymbol).assertIsDisplayed() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapSelectTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapSelectTokenScreenTest.kt index e474212036..cc654f9b2c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapSelectTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapSelectTokenScreenTest.kt @@ -20,7 +20,6 @@ class SwapSelectTokenScreenTest : BaseTestCase() { @DisplayName("Open 'Swap select token' screen from 'Main' screen") @Test fun openSwapSelectTokenScreenFromMainScreenTest() { - val swapTokenName = "Ethereum" val receiveTokenName = "Polygon" setupHooks().run { @@ -37,14 +36,11 @@ class SwapSelectTokenScreenTest : BaseTestCase() { step("Close 'Stories' screen") { onSwapStoriesScreen { closeButton.clickWithAssertion() } } - step("Assert 'Swap select token' screen title is displayed") { - onSwapSelectTokenScreen { title.assertIsDisplayed() } + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } } - step("Assert 'You swap' title is displayed") { - onSwapSelectTokenScreen { youSwapTitle.assertIsDisplayed() } - } - step("Assert 'You swap' block is displayed") { - onSwapSelectTokenScreen { youSwapBlock.assertIsDisplayed() } + step("Assert 'You receive' title is displayed") { + onSwapSelectTokenScreen { youReceiveTitle.assertIsDisplayed() } } step("Assert search icon is displayed") { onSwapSelectTokenScreen { searchBarIcon.assertIsDisplayed() } @@ -52,15 +48,6 @@ class SwapSelectTokenScreenTest : BaseTestCase() { step("Assert search placeholder is displayed") { onSwapSelectTokenScreen { searchBarPlaceholderText.assertIsDisplayed() } } - step("Click on token with name '$swapTokenName'") { - onSwapSelectTokenScreen { tokenWithName(swapTokenName).performClick() } - } - step("Assert 'You receive' title is displayed") { - onSwapSelectTokenScreen { youReceiveTitle.assertIsDisplayed() } - } - step("Assert 'You receive' block is displayed") { - onSwapSelectTokenScreen { youReceiveBlock.assertIsDisplayed() } - } step("Click on token with name '$receiveTokenName'") { onSwapSelectTokenScreen { tokenWithName(receiveTokenName).performClick() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt index 22046cdc8a..0336d3895d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt @@ -32,6 +32,7 @@ class SwapTokenScreenTest : BaseTestCase() { fun networkFeeTest() { val inputAmount = "400" val tokenTitle = "Polygon" + val receiveTokenName = "Ethereum" setupHooks().run { @@ -67,13 +68,6 @@ class SwapTokenScreenTest : BaseTestCase() { } } } - step("Assert receive amount is displayed") { - onSwapTokenScreen { - flakySafely(WAIT_UNTIL_TIMEOUT) { - receiveAmount.assertIsDisplayed() - } - } - } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -81,9 +75,19 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert input amount = '$inputAmount'") { onSwapTokenScreen { textInput.assertTextEquals(inputAmount) } } + step("Assert receive amount is displayed") { + onSwapTokenScreen { + flakySafely(WAIT_UNTIL_TIMEOUT) { + receiveAmount.assertIsDisplayed() + } + } + } step("Assert 'Providers' block is displayed") { onSwapTokenScreen { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { @@ -175,9 +179,10 @@ class SwapTokenScreenTest : BaseTestCase() { @Test fun changeNetworkFeeTest() { val inputAmount = "400" + val tokenTitle = "Polygon" + val receiveTokenName = "Ethereum" setupHooks().run { - val tokenTitle = "Polygon" step("Open 'Main Screen'") { openMainScreen() @@ -207,13 +212,6 @@ class SwapTokenScreenTest : BaseTestCase() { } } } - step("Assert receive amount is displayed") { - onSwapTokenScreen { - flakySafely(WAIT_UNTIL_TIMEOUT) { - receiveAmount.assertIsDisplayed() - } - } - } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -224,6 +222,16 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert input amount = '$inputAmount'") { onSwapTokenScreen { textInput.assertTextEquals(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } + step("Assert receive amount is displayed") { + onSwapTokenScreen { + flakySafely(WAIT_UNTIL_TIMEOUT) { + receiveAmount.assertIsDisplayed() + } + } + } step("Assert 'Network fee' block is displayed") { onSwapTokenScreen { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { @@ -270,11 +278,10 @@ class SwapTokenScreenTest : BaseTestCase() { ApiEnvConfig(ApiConfig.ID.Express, ApiEnvironment.PROD) ) @AllureId("2828") - @DisplayName("Swap: network fee") + @DisplayName("Swap: go to token swap") @Test fun goToTokenSwapTest() { val swapTokenSymbol = "POL" - val receiveTokenSymbol = "ETH" val tokenTitle = "Polygon" setupHooks().run { @@ -314,8 +321,8 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert token symbol: '$swapTokenSymbol' is displayed") { onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() } } - step("Assert token symbol: '$receiveTokenSymbol' is displayed") { - onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } + step("Assert 'Choose token' button is displayed") { + onSwapTokenScreen { chooseTokenButton.assertIsDisplayed() } } } } @@ -329,6 +336,7 @@ class SwapTokenScreenTest : BaseTestCase() { fun checkSwapUiTest() { val swapTokenSymbol = "POL" val receiveTokenSymbol = "ETH" + val receiveTokenName = "Ethereum" val newReceiveToken = "POL (ex-MATIC)" val tokenTitle = "Polygon" val inputAmount = "1" @@ -356,14 +364,17 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert swap token symbol: '$swapTokenSymbol' is displayed") { onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") { onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } } - step("Click on 'Select token' icon") { - onSwapTokenScreen { selectTokenIcon.performClick() } + step("Click on receive 'Select token' icon") { + onSwapTokenScreen { receiveSelectTokenIcon.performClick() } } step("Select new receive token: $newReceiveToken") { - onSwapChooseTokenScreen { tokenWithTitle(newReceiveToken).performClick() } + onSwapSelectTokenScreen { tokenWithName(newReceiveToken).performClick() } } step("Assert new receive token symbol: '$swapTokenSymbol' is displayed") { onSwapTokenScreen { receiveTokenSymbol(swapTokenSymbol).assertIsDisplayed() } @@ -392,7 +403,11 @@ class SwapTokenScreenTest : BaseTestCase() { onSwapTokenScreen { swapFiatAmount.assertIsDisplayed() } } step("Assert receive token fiat amount is displayed") { - onSwapTokenScreen { receiveFiatAmount.assertIsDisplayed() } + onSwapTokenScreen { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + receiveFiatAmount.assertIsDisplayed() + } + } } step("Assert 'Swap tokens on screen' button is displayed") { onSwapTokenScreen { @@ -416,6 +431,7 @@ class SwapTokenScreenTest : BaseTestCase() { fun checkSwapTokensSwitchTest() { val swapTokenSymbol = "POL" val receiveTokenSymbol = "ETH" + val receiveTokenName = "Ethereum" val tokenTitle = "Polygon" setupHooks().run { @@ -438,6 +454,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert swap token symbol: '$swapTokenSymbol' is displayed") { onSwapTokenScreen { swapTokenSymbol(swapTokenSymbol).assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert receive token symbol: '$receiveTokenSymbol' is displayed") { onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } } @@ -514,6 +533,7 @@ class SwapTokenScreenTest : BaseTestCase() { @Test fun enableToCoverMarketAndFastFeeTest() { val tokenName = "Ethereum" + val receiveTokenName = "Polygon" val inputAmount = "0.99" val market = "Market" val fast = "Fast" @@ -544,6 +564,9 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Select '$market' fee type") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { selectFeeType(FeeType.Market, selectedFeeAmount = marketFeeAmount) @@ -562,6 +585,7 @@ class SwapTokenScreenTest : BaseTestCase() { @Test fun unableToCoverMarketAndFastFeeTest() { val tokenName = "POL (ex-MATIC)" + val receiveTokenName = "Ethereum" val inputAmount = "0.0001" val marketFeeType = "Market" val fastFeeType = "Fast" @@ -587,6 +611,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } + step("Swipe up") { + swipeVertical(SwipeDirection.UP) + } step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } @@ -603,6 +630,9 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Select '$marketFeeType' fee type") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { selectFeeType(feeType = FeeType.Market, feeAmount) @@ -633,6 +663,7 @@ class SwapTokenScreenTest : BaseTestCase() { @Test fun unableToCoverFastFeeTest() { val tokenName = "POL (ex-MATIC)" + val receiveTokenName = "Ethereum" val inputAmount = "3000" val fastFeeType = "Fast" val fastFeeAmount = "$2," @@ -660,6 +691,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } + step("Swipe up") { + swipeVertical(SwipeDirection.UP) + } step("Click on token with name: '$tokenName'") { onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } } @@ -676,6 +710,9 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Swap' button is enabled") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onSwapTokenScreen { swapButton.assertIsEnabled() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt index e61314adb3..3b220ce5d5 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt @@ -15,6 +15,7 @@ import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.scenarios.SwapEntryPoint import com.tangem.scenarios.chackUnableToCoverFeeNotification import com.tangem.scenarios.checkSwapWarning +import com.tangem.scenarios.chooseReceiveToken import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openSwapScreen import com.tangem.scenarios.synchronizeAddresses @@ -36,6 +37,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun checkSwapInsufficientFundsWarningTest() { val tokenTitle = "Polygon" + val receiveTokenTitle = "Ethereum" val inputAmount = "1000" setupHooks().run { @@ -65,6 +67,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenTitle) + } step("Assert 'Insufficient funds' error is displayed") { waitForIdle() onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } @@ -127,6 +132,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(networkName) + } step("Check 'Unable to cover '$networkName' fee notification") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { chackUnableToCoverFeeNotification(networkName = networkName, currencySymbol = currencySymbol) @@ -143,6 +151,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun checkHighPriceImpactWarningCEXTest() { val tokenTitle = "USDC" + val receiveTokenName = "Solana" val inputAmount = "100" val currencySymbol = "SOL" val slippagePercent = "5%" @@ -202,6 +211,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert fiat amount with warning is displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onSwapTokenScreen { @@ -236,6 +248,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun checkHighPriceImpactWarningDEXTest() { val tokenTitle = "Polygon" + val receiveTokenName = "Ethereum" val inputAmount = "1000" val slippagePercent = "3.5%" val dialogTitle = getResourceString(R.string.swapping_alert_title) @@ -287,6 +300,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert fiat amount with warning is displayed") { onSwapTokenScreen { receiveFiatAmount.assertTextContains("%", substring = true) } } @@ -323,6 +339,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun solanaRemainingBalanceEqualToZeroWarningTest() { val tokenTitle = "Solana" + val receiveTokenName = "USDC" val inputAmount = "0.00168933" val tokensScenarioState = "SolanaUSDC" val rentAmount = "SOL 0.00089088" @@ -371,6 +388,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Invalid amount' warning is not displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( @@ -388,6 +408,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun solanaRemainingBalanceEqualToRentAmountTest() { val tokenTitle = "Solana" + val receiveTokenName = "USDC" val inputAmount = "0.001689338" val tokensScenarioState = "SolanaUSDC" val rentAmount = "SOL 0.00089088" @@ -436,6 +457,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Invalid amount' warning is not displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( @@ -454,6 +478,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun solanaRemainingBalanceMoreThanRentAmountTest() { val tokenTitle = "Solana" + val receiveTokenName = "USDC" val inputAmount = "0.0000941" val tokensScenarioState = "SolanaUSDC" val rentAmount = "SOL 0.00089088" @@ -502,6 +527,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Invalid amount' warning is not displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( @@ -519,6 +547,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { @Test fun solanaRemainingBalanceLessThanRentAmountTest() { val tokenTitle = "Solana" + val receiveTokenName = "USDC" val inputAmount = "0.0016941" val tokensScenarioState = "SolanaUSDC" val rentAmount = "SOL 0.00089088" @@ -567,6 +596,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert 'Invalid amount' warning is displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt index 59a5811c90..2e8dcfbea1 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/MockProvider.kt @@ -27,6 +27,7 @@ object MockProvider { "Wallet 2 (No Backup)" to Wallet2NoBackupMockContent, "Wallet 2 (No Backup, No Wallets)" to Wallet2NoBackupNoWalletsMockContent, "Wallet 2 (Seed Phrase)" to Wallet2WithSeedPhraseMockContent, + "Wallet 2 (With derivations)" to Wallet2WithDerivationsMockContent, "Shiba" to ShibaMockContent, "Shiba (No Backup)" to ShibaNoBackupMockContent, "Shiba (No Backup, No Wallets)" to ShibaNoBackupNoWalletsMockContent, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithDerivationsMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithDerivationsMockContent.kt new file mode 100644 index 0000000000..bc976b765a --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/Wallet2WithDerivationsMockContent.kt @@ -0,0 +1,59 @@ +package com.tangem.tap.domain.sdk.mocks.content + +import com.tangem.common.card.EllipticCurve +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.operations.derivation.DerivationTaskResponse +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.sdk.api.CreateProductWalletTaskResponse +import com.tangem.tap.domain.sdk.mocks.MockContent + +/** + * Mock content for UI tests that need the Wallet 2 derivation style (V3) AND the full set of derivations + * provided by [WalletMockContent]. + * + * Why it exists: + * - [WalletMockContent] is Wallet 1 (derivation style V2), so for Bitcoin the card's default path resolves to + * `m/44'/0'/0'/0/0`. WireMock `/user-tokens` stubs send `m/84'/0'/0'/0/0` → the path does not match the card default + * → `Network.DerivationPath.Custom` → `CryptoCurrency.isCustom == true`. That hides the swap-to-another-token button + * introduced in the Send flow and causes the Swap receive card to stay in Empty/Loading state. + * - [Wallet2MockContent] is V3 (matches WireMock) but its `derivationTaskResponse` is keyed by the wrong wallet + * public key (inherited from [WalletMockContent]) and only contains a handful of derivation paths. As a result + * address synchronisation fails in tests that need more than BTC/ETH/BCH/DOGE. + * + * This mock combines both: Wallet 2 card DTO from [Wallet2MockContent] (V3 derivation style) + full derivation + * entries from [WalletMockContent] re-keyed to [Wallet2MockContent]'s own wallet public keys. + */ +object Wallet2WithDerivationsMockContent : MockContent by Wallet2MockContent { + + private val secp256k1Pubkey: ByteArray = + Wallet2MockContent.cardDto.wallets.first { it.curve == EllipticCurve.Secp256k1 }.publicKey + + private val ed25519Pubkey: ByteArray = + Wallet2MockContent.cardDto.wallets.first { it.curve == EllipticCurve.Ed25519 }.publicKey + + override val derivationTaskResponse: DerivationTaskResponse = DerivationTaskResponse( + entries = rekey(WalletMockContent.derivationTaskResponse.entries), + ) + + override val createProductWalletTaskResponse: CreateProductWalletTaskResponse = + CreateProductWalletTaskResponse( + card = Wallet2MockContent.cardDto, + derivedKeys = rekey(WalletMockContent.createProductWalletTaskResponse.derivedKeys), + primaryCard = Wallet2MockContent.createProductWalletTaskResponse.primaryCard, + ) + + /** + * Takes an entry map keyed by [WalletMockContent]'s wallet public keys (Secp256k1 first, Ed25519 second in + * insertion order) and re-keys it to [Wallet2MockContent]'s own wallet public keys so that + * [com.tangem.data.wallets.derivations.DerivationsSource] lookups by card wallet pubkey succeed. + */ + private fun rekey( + sourceEntries: Map, + ): Map { + val values = sourceEntries.values.toList() + return buildMap { + values.getOrNull(index = 0)?.let { put(ByteArrayKey(secp256k1Pubkey), it) } + values.getOrNull(index = 1)?.let { put(ByteArrayKey(ed25519Pubkey), it) } + } + } +} \ No newline at end of file From f086d6bb74e566f2fca5805593a0684d9c0a24f9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Apr 2026 18:11:27 +0400 Subject: [PATCH 132/206] Updated on 2026-08-14 --- .../src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt index 28c76f1551..91298fd491 100644 --- a/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt +++ b/common/src/main/kotlin/com/tangem/common/TangemBlogUrlBuilder.kt @@ -7,7 +7,7 @@ object TangemBlogUrlBuilder { suspend fun build(post: Post): String { return TangemSiteUrlBuilder.url( - path = "/blog/post/${post.path}/", + path = "/embed/blog/post/${post.path}/", campaign = "articles", ) } From 40fe8bb5cbce6f3c5176474abd4c97edf63c1d6c Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Apr 2026 20:50:41 +0500 Subject: [PATCH 133/206] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 - .../models/response/ReissueCardResponse.kt | 2 +- data/account/build.gradle.kts | 2 - .../DefaultSingleAccountListProducer.kt | 13 +- .../DefaultSingleAccountListProducerTest.kt | 19 +- .../tangem/data/pay/di/TangemPayDataModule.kt | 20 -- .../DefaultPaymentAccountStatusFetcher.kt | 4 +- .../DefaultCustomerOrderRepository.kt | 10 +- .../DefaultReissueCardRepository.kt | 7 +- .../data/pay/util/OrderStatusConverter.kt | 16 ++ .../tokens/wallet/WalletBalanceFetcher.kt | 26 +-- .../tokens/wallet/WalletBalanceFetcherTest.kt | 15 -- .../tangem/domain/pay/model/OrderStatus.kt | 14 -- .../TangemPayMainScreenCustomerInfoUseCase.kt | 179 ------------------ features/details/impl/build.gradle.kts | 1 - .../tangempay/TangemPayFeatureToggles.kt | 4 +- .../DefaultTangemPayFeatureToggles.kt | 10 +- .../tangempay/di/TangemPayDetailsModule.kt | 5 +- .../tangempay/model/TangemPayCardPageModel.kt | 2 +- features/tokendetails/impl/build.gradle.kts | 1 - .../DefaultTokenDetailsDeepLinkHandler.kt | 7 +- .../DefaultTokenDetailsDeepLinkHandlerTest.kt | 6 - features/wallet/impl/build.gradle.kts | 1 - .../wallet/child/wallet/model/WalletModel.kt | 20 +- .../model/intents/TangemPayClickIntents.kt | 7 +- .../preview/WalletScreenPreviewDataLegacy.kt | 7 - .../WalletTangemPayAnalyticsEventSender.kt | 31 ++- .../wallet/domain/WalletContentFetcher.kt | 12 +- .../wallet/state/model/TangemPayState.kt | 49 ----- .../wallet/state/model/WalletState.kt | 6 - .../transformers/AddWalletTransformer.kt | 2 - .../InitializeWalletsTransformer.kt | 2 - .../ReinitializeNewWalletTransformer.kt | 2 - .../ReinitializeWalletTransformer.kt | 2 - .../TangemPayExposedDeviceTransformer.kt | 22 --- .../TangemPayHiddenStateTransformer.kt | 23 --- ...TangemPayHideOnboardingStateTransformer.kt | 3 +- .../TangemPayLoadingStateTransformer.kt | 20 -- ...ngemPayOnboardingBannerStateTransformer.kt | 30 --- .../TangemPayRefreshNeededStateTransformer.kt | 39 ---- ...TangemPayRefreshShowProgressTransformer.kt | 11 +- .../TangemPayUnavailableStateTransformer.kt | 28 --- .../TangemPayUpdateInfoStateTransformer.kt | 130 ------------- .../transformers/UnlockWalletTransformer.kt | 4 +- .../state/utils/WalletLoadingStateFactory.kt | 5 - .../subscribers/TangemPayMainSubscriber.kt | 106 ++--------- .../presentation/wallet/ui/WalletScreen.kt | 11 +- .../singlecurrency/TangemPayCardMainBlock.kt | 103 ---------- .../visa/TangemPayExposedDeviceState.kt | 41 ---- .../visa/TangemPayFailedIssueState.kt | 36 ---- .../visa/TangemPayLoadingScreenBlock.kt | 37 ---- .../visa/TangemPayMainScreenBlock.kt | 106 ----------- .../visa/TangemPayOnboardingBanner.kt | 120 ------------ .../components/visa/TangemPayProgressState.kt | 35 ---- .../components/visa/TangemPayRefreshBlock.kt | 79 -------- .../components/visa/TangemUnavailableBlock.kt | 76 -------- 56 files changed, 82 insertions(+), 1491 deletions(-) create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderStatusConverter.kt delete mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TangemPayState.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayExposedDeviceTransformer.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHiddenStateTransformer.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayLoadingStateTransformer.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayOnboardingBannerStateTransformer.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshNeededStateTransformer.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUnavailableStateTransformer.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/singlecurrency/TangemPayCardMainBlock.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayExposedDeviceState.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayFailedIssueState.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayLoadingScreenBlock.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayMainScreenBlock.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayOnboardingBanner.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayProgressState.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayRefreshBlock.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemUnavailableBlock.kt diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index d4053a9ee6..c161b3d49d 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -27,10 +27,6 @@ "name": "GASLESS_APPROVAL_ENABLED", "version": "5.37" }, - { - "name": "TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED", - "version": "5.37" - }, { "name": "DYNAMIC_ADDRESSES_ENABLED", "version": "undefined" diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt index 49fb34198e..4b173d08f3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/ReissueCardResponse.kt @@ -10,6 +10,6 @@ data class ReissueCardResponse( @JsonClass(generateAdapter = true) data class Result( @Json(name = "order_id") val orderId: String, - @Json(name = "status") val status: String, + @Json(name = "status") val status: OrderResponse.Result.Status, ) } \ No newline at end of file diff --git a/data/account/build.gradle.kts b/data/account/build.gradle.kts index 9f3fa0d8a4..9e1cd33fea 100644 --- a/data/account/build.gradle.kts +++ b/data/account/build.gradle.kts @@ -36,8 +36,6 @@ dependencies { api(projects.domain.visa) // endregion - implementation(projects.features.tangempay.details.api) // Remove after TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED - // region Project - Data implementation(projects.data.common) // endregion diff --git a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt index 28fd5fd6be..fcad8c9615 100644 --- a/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt +++ b/data/account/src/main/kotlin/com/tangem/data/account/producer/DefaultSingleAccountListProducer.kt @@ -11,7 +11,6 @@ import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.core.flow.FlowProducerTools import com.tangem.domain.models.account.Account import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.hot.sdk.model.HotWalletId import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.assisted.Assisted @@ -36,7 +35,6 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( @Assisted val params: SingleAccountListProducer.Params, override val flowProducerTools: FlowProducerTools, private val walletAccountListFlowFactory: WalletAccountListFlowFactory, - private val tangemPayFeatureToggles: TangemPayFeatureToggles, private val userWalletsListRepository: UserWalletsListRepository, private val dispatchers: CoroutineDispatcherProvider, ) : SingleAccountListProducer { @@ -45,16 +43,6 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( @OptIn(ExperimentalCoroutinesApi::class) override fun produce(): Flow { - val accountListFlow: Flow = if (tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled) { - combineWithPaymentAccount() - } else { - walletAccountListFlowFactory.create(userWalletId = params.userWalletId) - } - - return accountListFlow.flowOn(dispatchers.default) - } - - private fun combineWithPaymentAccount(): Flow { return walletAccountListFlowFactory.create(params.userWalletId) .map { accountList -> val userWallet = userWalletsListRepository.getSyncStrict(id = params.userWalletId) @@ -66,6 +54,7 @@ internal class DefaultSingleAccountListProducer @AssistedInject constructor( accountList } } + .flowOn(dispatchers.default) } private fun UserWallet.isPaymentAccountSupported(): Boolean = when (this) { diff --git a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt index 4837e4f2a6..d4138d111c 100644 --- a/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt +++ b/data/account/src/test/java/com/tangem/data/account/producer/DefaultSingleAccountListProducerTest.kt @@ -8,7 +8,7 @@ import com.tangem.domain.core.flow.FlowProducerTools import com.tangem.domain.models.TokensSortType import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.tangempay.TangemPayFeatureToggles +import com.tangem.hot.sdk.model.HotWalletId import com.tangem.test.core.getEmittedValues import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.* @@ -31,21 +31,22 @@ class DefaultSingleAccountListProducerTest { private val userWalletId = UserWalletId("011") private val flowProducerTools: FlowProducerTools = mockk() - private val tangemPayFeatureToggles = mockk { - every { this@mockk.isTangemPayAccountsRefactorEnabled } returns false + private val userWallet = mockk { + every { walletId } returns userWalletId + every { hotWalletId } returns mockk { + every { authType } returns HotWalletId.AuthType.NoPassword + } } - private val userWalletsListRepository = mockk() - private val userWallet = mockk { - every { this@mockk.walletId } returns userWalletId + private val userWalletsListRepository = mockk { + every { userWallets } returns MutableStateFlow?>(value = listOf(userWallet)) } private val producer = DefaultSingleAccountListProducer( params = SingleAccountListProducer.Params(userWalletId = userWalletId), walletAccountListFlowFactory = walletAccountListFlowFactory, - dispatchers = TestingCoroutineDispatcherProvider(), flowProducerTools = flowProducerTools, - tangemPayFeatureToggles = tangemPayFeatureToggles, userWalletsListRepository = userWalletsListRepository, + dispatchers = TestingCoroutineDispatcherProvider(), ) @AfterEach @@ -56,8 +57,6 @@ class DefaultSingleAccountListProducerTest { @Test fun produce() = runTest { // Arrange - MutableStateFlow(listOf(userWallet)) - val accountList = AccountList.empty(userWalletId) every { walletAccountListFlowFactory.create(userWalletId) } returns flowOf(accountList) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 97394190aa..3187eeba11 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -11,7 +11,6 @@ import com.tangem.data.pay.converter.PaymentAccountStatusValueDMConverter import com.tangem.data.pay.flow.DefaultPaymentAccountStatusFetcher import com.tangem.data.pay.flow.DefaultPaymentAccountStatusProducer import com.tangem.data.pay.repository.* -import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.data.pay.usecase.DefaultGetTangemPayCurrencyStatusUseCase import com.tangem.data.pay.usecase.DefaultGetTangemPayCustomerIdUseCase @@ -30,12 +29,10 @@ import com.tangem.domain.pay.repository.* import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase -import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase import com.tangem.domain.tangempay.repository.TangemPayTxHistoryRepository -import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Binds @@ -167,23 +164,6 @@ internal interface TangemPayDataModule { return GetPaymentAccountCryptoCurrencyStatusUseCase(paymentAccountStatusSupplier) } - @Provides - @Singleton - fun provideTangemPayMainScreenCustomerInfoUseCase( - repository: OnboardingRepository, - customerOrderRepository: CustomerOrderRepository, - tangemPayOnboardingRepository: OnboardingRepository, - eligibilityManager: TangemPayEligibilityManager, - deviceSecurity: DeviceSecurityInfoProvider, - ): TangemPayMainScreenCustomerInfoUseCase { - return TangemPayMainScreenCustomerInfoUseCase( - onboardingRepository = repository, - customerOrderRepository = customerOrderRepository, - eligibilityManager = eligibilityManager, - deviceSecurity = deviceSecurity, - ) - } - @Provides @Singleton fun provideProduceTangemPayInitialDataUseCase( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index b9bfca6812..a278b0a4dd 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -11,8 +11,8 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardLimitData -import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.OrderData @@ -188,7 +188,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( when (orderData.status) { OrderStatus.CANCELED -> handleCanceledOrder(account, orderData) OrderStatus.COMPLETED -> handleCompletedOrder(account) - OrderStatus.UNKNOWN -> PaymentAccountStatusValue.Error.Unavailable OrderStatus.NEW, OrderStatus.PROCESSING, -> { @@ -228,7 +227,6 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( OrderStatus.COMPLETED -> return handleCompletedOrder(account) OrderStatus.NEW, OrderStatus.PROCESSING, - OrderStatus.UNKNOWN, -> Unit // Continue polling } }, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt index 8cc392d6ed..8871a7d804 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultCustomerOrderRepository.kt @@ -1,8 +1,8 @@ package com.tangem.data.pay.repository import arrow.core.Either +import com.tangem.data.pay.util.OrderStatusConverter import com.tangem.datasource.api.pay.TangemPayApi -import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.OrderData import com.tangem.domain.pay.model.OrderStatus @@ -19,13 +19,7 @@ internal class DefaultCustomerOrderRepository @Inject constructor( return requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.getOrder(authHeader = authHeader, orderId = orderId) }.map { response -> - val status = when (response.result?.status) { - null -> OrderStatus.PROCESSING - OrderResponse.Result.Status.NEW -> OrderStatus.NEW - OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING - OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED - OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED - } + val status = response.result?.status?.let(OrderStatusConverter::convert) ?: OrderStatus.PROCESSING OrderData( customerId = response.result?.customerId.orEmpty(), status = status, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt index b6ee3b4b76..0e85fa2f63 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.raise.either import arrow.core.right import com.tangem.core.error.UniversalError +import com.tangem.data.pay.util.OrderStatusConverter import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.ReissueCardRequest import com.tangem.datasource.api.pay.models.response.OrderResponse @@ -59,8 +60,10 @@ internal class DefaultReissueCardRepository @Inject constructor( body = ReissueCardRequest(cardId = cardId), ) }.bind() - - TangemPayReissueOrderInfo(response.result.orderId, OrderStatus.fromString(response.result.status)) + TangemPayReissueOrderInfo( + orderId = response.result.orderId, + orderStatus = OrderStatusConverter.convert(response.result.status), + ) } override suspend fun storeReissueOrderId(cardId: String, orderId: String): Either = diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderStatusConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderStatusConverter.kt new file mode 100644 index 0000000000..2c143fd252 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/OrderStatusConverter.kt @@ -0,0 +1,16 @@ +package com.tangem.data.pay.util + +import com.tangem.datasource.api.pay.models.response.OrderResponse +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.utils.converter.Converter + +internal object OrderStatusConverter : Converter { + override fun convert(value: OrderResponse.Result.Status): OrderStatus { + return when (value) { + OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING + OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED + OrderResponse.Result.Status.NEW -> OrderStatus.NEW + OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED + } + } +} \ No newline at end of file diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt index ccaee70b62..8f43d68f33 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcher.kt @@ -1,7 +1,6 @@ package com.tangem.domain.tokens.wallet import arrow.core.Either -import arrow.core.right import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.common.tokens.CardCryptoCurrencyFactory import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -147,18 +146,10 @@ class WalletBalanceFetcher internal constructor( fetchExpressAssets(userWallet = userWallet, currencies = currencies) - fetcher.fetch( - userWalletId = userWalletId, - currencies = currencies, - paymentAccountRefactorEnabled = params.isPaymentAccountRefactorEnabled, - ) + fetcher.fetch(userWalletId = userWalletId, currencies = currencies) } - private suspend fun BaseWalletBalanceFetcher.fetch( - userWalletId: UserWalletId, - currencies: Set, - paymentAccountRefactorEnabled: Boolean, - ) { + private suspend fun BaseWalletBalanceFetcher.fetch(userWalletId: UserWalletId, currencies: Set) { coroutineScope { // Fetch balance sources in parallel val balanceErrors = fetchingSources.filterIsInstance() @@ -182,7 +173,7 @@ class WalletBalanceFetcher internal constructor( // Fetch TangemPay separately — may run long-polling, so it must not block balance error checking if (fetchingSources.any { it is WalletFetchingSource.TangemPay }) { - fetchPaymentAccount(userWalletId, paymentAccountRefactorEnabled) + paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } } } @@ -197,19 +188,10 @@ class WalletBalanceFetcher internal constructor( expressServiceFetcher.fetch(userWallet = userWallet, assetIds = assetIds) } - private suspend fun fetchPaymentAccount( - userWalletId: UserWalletId, - paymentAccountRefactorEnabled: Boolean, - ): Either { - if (!paymentAccountRefactorEnabled) return Unit.right() - - return paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) - } - /** * Params of [WalletBalanceFetcher] * * @property userWalletId user wallet id */ - data class Params(val userWalletId: UserWalletId, val isPaymentAccountRefactorEnabled: Boolean) + data class Params(val userWalletId: UserWalletId) } \ No newline at end of file diff --git a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt index c3ab25891d..a345573819 100644 --- a/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt +++ b/domain/tokens/src/test/kotlin/com/tangem/domain/tokens/wallet/WalletBalanceFetcherTest.kt @@ -96,7 +96,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -133,7 +132,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -171,7 +169,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -209,7 +206,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -260,7 +256,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -315,7 +310,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -376,7 +370,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -428,7 +421,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -479,7 +471,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -536,7 +527,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -607,7 +597,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -682,7 +671,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -743,7 +731,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -802,7 +789,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) @@ -869,7 +855,6 @@ internal class WalletBalanceFetcherTest { val actual = fetcher( params = WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = false, ), ) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index c304832170..fd3d717107 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -1,22 +1,8 @@ package com.tangem.domain.pay.model -import java.util.Locale - enum class OrderStatus { - UNKNOWN, // TODO remove it after TangemPay accounts refactor TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED NEW, PROCESSING, COMPLETED, CANCELED, - ; - - companion object { - fun fromString(value: String) = when (value.uppercase(Locale.US)) { - "NEW" -> NEW - "PROCESSING" -> PROCESSING - "COMPLETED" -> COMPLETED - "CANCELED" -> CANCELED - else -> UNKNOWN - } - } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt deleted file mode 100644 index 04780f63c1..0000000000 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/TangemPayMainScreenCustomerInfoUseCase.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.tangem.domain.pay.usecase - -import arrow.core.Either -import arrow.core.left -import arrow.core.right -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayEligibilityManager -import com.tangem.domain.pay.model.* -import com.tangem.domain.pay.repository.CustomerOrderRepository -import com.tangem.domain.pay.repository.OnboardingRepository -import com.tangem.domain.visa.error.VisaApiError -import com.tangem.security.DeviceSecurityInfoProvider -import com.tangem.security.isSecurityExposed -import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.flow.* - -class TangemPayMainScreenCustomerInfoUseCase( - private val onboardingRepository: OnboardingRepository, - private val customerOrderRepository: CustomerOrderRepository, - private val eligibilityManager: TangemPayEligibilityManager, - private val deviceSecurity: DeviceSecurityInfoProvider, -) { - - val state: StateFlow>> - field = MutableStateFlow(value = mapOf()) - - private val logger = TangemLogger.withTag("TangemPayMainScreenCustomerInfoUseCase") - - suspend fun fetch(userWalletId: UserWalletId) { - logger.i("fetch: ${userWalletId.stringValue}") - - if (onboardingRepository.isTangemPayDeactivated(userWalletId)) { - updateState(userWalletId, MainCustomerInfoContentState.Empty.right()) - return - } - - if (deviceSecurity.isSecurityExposed()) { - logger.i("fetch security info: rooted: ${deviceSecurity.isRooted}") - logger.i("fetch security info: xposed: ${deviceSecurity.isXposed}") - logger.i("fetch security info: bootloader unlocked: ${deviceSecurity.isBootloaderUnlocked}") - - updateState(userWalletId = userWalletId, either = TangemPayCustomerInfoError.ExposedDeviceError.left()) - return // fast exit - } - - onboardingRepository.hasTangemPayInWallet(userWalletId) - .fold( - ifLeft = { error -> - logger.e("Failed checkCustomerWallet for $userWalletId: ${error.javaClass.simpleName}") - if (error is VisaApiError.NotPaeraCustomer) { - showOnboardingBannerIfEligible(userWalletId) - } else { - updateState(userWalletId, TangemPayCustomerInfoError.UnknownError.left()) - } - }, - ifRight = { hasTangemPay -> - logger.i("checkCustomerWallet for $userWalletId: $hasTangemPay") - if (hasTangemPay) { - val oldResult = state.value[userWalletId] - if (oldResult == null) { - updateState(userWalletId, MainCustomerInfoContentState.Loading.right()) - } - - val result = proceedWithPaeraCustomerResult(userWalletId) - if (result.leftOrNull() is TangemPayCustomerInfoError.DeactivatedError) { - updateState(userWalletId, MainCustomerInfoContentState.Empty.right()) - return - } - updateState(userWalletId, result.map(MainCustomerInfoContentState::Content)) - } else { - // if there's no tangem pay, check eligibility and show onboarding banner - showOnboardingBannerIfEligible(userWalletId) - } - }, - ) - } - - private suspend fun showOnboardingBannerIfEligible(userWalletId: UserWalletId) { - val tangemPayEntryPoint = TangemPayEntryPoint.BANNER - if (eligibilityManager.isPaeraCustomerForAnyWallet(tangemPayEntryPoint)) { - updateState(userWalletId, MainCustomerInfoContentState.Empty.right()) - return - } - val isEligible = eligibilityManager - .getEligibleWallets( - shouldExcludePaeraCustomers = false, - entryPoint = tangemPayEntryPoint, - ) - .any { it.walletId == userWalletId } - if (isEligible) { - if (onboardingRepository.getHideMainOnboardingBanner(userWalletId)) { - updateState(userWalletId, MainCustomerInfoContentState.Empty.right()) - } else { - updateState(userWalletId, MainCustomerInfoContentState.OnboardingBanner.right()) - } - } else { - updateState(userWalletId, MainCustomerInfoContentState.Empty.right()) - } - } - - operator fun invoke( - userWalletId: UserWalletId, - ): Flow> { - return state.mapNotNull { map -> map[userWalletId] } - } - - private fun updateState( - userWalletId: UserWalletId, - either: Either, - ) { - state.update { currentMap -> - currentMap.toMutableMap().apply { this[userWalletId] = either } - } - } - - private suspend fun proceedWithPaeraCustomerResult( - userWalletId: UserWalletId, - ): Either { - if (!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)) { - return TangemPayCustomerInfoError.RefreshNeededError.left() - } - val orderId = onboardingRepository.getOrderId(userWalletId) - return if (orderId != null) { - proceedWithOrderId(userWalletId = userWalletId, orderId = orderId) - } else { - proceedWithoutOrder(userWalletId = userWalletId) - } - } - - private suspend fun proceedWithoutOrder( - userWalletId: UserWalletId, - ): Either { - return onboardingRepository.getCustomerInfo(userWalletId) - .mapLeft { error -> - logger.e("mapErrorForCustomer: $error") - error.mapErrorForCustomer() - } - .map { customerInfo -> - logger.i("customerInfo") - if (customerInfo.productInstance == null) { - onboardingRepository.createOrder(userWalletId) - MainScreenCustomerInfo(info = customerInfo, orderStatus = OrderStatus.NEW) - } else { - MainScreenCustomerInfo(info = customerInfo, orderStatus = OrderStatus.COMPLETED) - } - } - } - - private suspend fun proceedWithOrderId( - userWalletId: UserWalletId, - orderId: String, - ): Either { - return customerOrderRepository.getOrderData(userWalletId, orderId = orderId) - .fold( - ifLeft = { error -> - error.mapErrorForCustomer().left() - }, - ifRight = { orderData -> - if (orderData.status in setOf(OrderStatus.COMPLETED, OrderStatus.UNKNOWN)) { - onboardingRepository.clearOrderId(userWalletId) - } - onboardingRepository.getCustomerInfo(userWalletId = userWalletId) - .mapLeft { it.mapErrorForCustomer() } - .map { customerInfo -> - MainScreenCustomerInfo(info = customerInfo, orderStatus = orderData.status) - } - }, - ) - } - - private fun VisaApiError.mapErrorForCustomer(): TangemPayCustomerInfoError { - return when (this) { - is VisaApiError.RefreshTokenExpired -> TangemPayCustomerInfoError.RefreshNeededError - is VisaApiError.NotPaeraCustomer -> TangemPayCustomerInfoError.UnknownError - is VisaApiError.Deactivated -> TangemPayCustomerInfoError.DeactivatedError - else -> TangemPayCustomerInfoError.UnavailableError - } - } -} \ No newline at end of file diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index d188416da8..e86fe43d33 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -19,7 +19,6 @@ dependencies { implementation(projects.features.disclaimer.api) implementation(projects.features.tester.api) implementation(projects.features.createWalletSelection.api) - implementation(projects.features.tangempay.details.api) implementation(projects.features.onboardingV2.api) /* Project - Core */ diff --git a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt index 0f2ae969eb..aca7d9b879 100644 --- a/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt +++ b/features/tangempay/details/api/src/main/kotlin/com/tangem/features/tangempay/TangemPayFeatureToggles.kt @@ -1,5 +1,3 @@ package com.tangem.features.tangempay -interface TangemPayFeatureToggles { - val isTangemPayAccountsRefactorEnabled: Boolean -} \ No newline at end of file +interface TangemPayFeatureToggles \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt index a46d768445..e8a0c42caf 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/DefaultTangemPayFeatureToggles.kt @@ -1,11 +1,3 @@ package com.tangem.features.tangempay -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager - -internal class DefaultTangemPayFeatureToggles( - private val featureTogglesManager: FeatureTogglesManager, -) : TangemPayFeatureToggles { - override val isTangemPayAccountsRefactorEnabled - get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED) -} \ No newline at end of file +internal class DefaultTangemPayFeatureToggles : TangemPayFeatureToggles \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsModule.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsModule.kt index a6ea142d28..6f0b9ea597 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsModule.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/di/TangemPayDetailsModule.kt @@ -1,6 +1,5 @@ package com.tangem.features.tangempay.di -import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.tangempay.DefaultTangemPayFeatureToggles import com.tangem.features.tangempay.TangemPayFeatureToggles import dagger.Module @@ -15,7 +14,7 @@ internal object TangemPayDetailsModule { @Provides @Singleton - fun provideTangemPayFeatureToggles(featureTogglesManager: FeatureTogglesManager): TangemPayFeatureToggles { - return DefaultTangemPayFeatureToggles(featureTogglesManager) + fun provideTangemPayFeatureToggles(): TangemPayFeatureToggles { + return DefaultTangemPayFeatureToggles() } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 5bee5538b2..59b353136e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -303,7 +303,7 @@ internal class TangemPayCardPageModel @Inject constructor( private fun onReissueOrderStatusReceived(orderStatus: OrderStatus) { when (orderStatus) { - OrderStatus.NEW, OrderStatus.PROCESSING, OrderStatus.COMPLETED, OrderStatus.UNKNOWN -> { + OrderStatus.NEW, OrderStatus.PROCESSING, OrderStatus.COMPLETED -> { uiState.update { state -> state.copy( addToWalletBlockState = null, diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 379ae600f2..d538fa28d5 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -111,7 +111,6 @@ dependencies { implementation(projects.features.sendV2.api) implementation(projects.features.tokenRecieve.api) implementation(projects.features.yieldSupply.api) - implementation(projects.features.tangempay.details.api) implementation(deps.decompose.ext.compose) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt index d912665f4e..e9ee88d2bd 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandler.kt @@ -23,7 +23,6 @@ import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents -import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger import com.tangem.utils.logging.TangemLogger @@ -47,7 +46,6 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( private val getUserWalletUseCase: GetUserWalletUseCase, private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, private val walletBalanceFetcher: WalletBalanceFetcher, - private val tangemPayFeatureToggles: TangemPayFeatureToggles, private val singleAccountListSupplier: SingleAccountListSupplier, ) : TokenDetailsDeepLinkHandler { @@ -133,10 +131,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor( currency = cryptoCurrency, ) !isMultiCurrency -> walletBalanceFetcher( - params = WalletBalanceFetcher.Params( - userWalletId = userWallet.walletId, - isPaymentAccountRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, - ), + params = WalletBalanceFetcher.Params(userWalletId = userWallet.walletId), ) } } diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt index 4be98e40a0..079ec97e5a 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt @@ -25,7 +25,6 @@ import com.tangem.domain.wallets.models.GetUserWalletError import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase -import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger import com.tangem.utils.logging.TangemLogger import io.mockk.* @@ -50,7 +49,6 @@ class DefaultTokenDetailsDeepLinkHandlerTest { private val analyticsEventHandler: AnalyticsEventHandler = mockk() private val getUserWalletUseCase: GetUserWalletUseCase = mockk() private val walletBalanceFetcher: WalletBalanceFetcher = mockk() - private val tangemPayFeatureToggles: TangemPayFeatureToggles = mockk() private val singleAccountListSupplier: SingleAccountListSupplier = mockk() @BeforeEach @@ -443,12 +441,10 @@ class DefaultTokenDetailsDeepLinkHandlerTest { userWalletId = userWalletId, cryptoCurrencies = listOf(expectedCryptoCurrency), ) - every { tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled } returns true coEvery { walletBalanceFetcher.invoke( WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = true ) ) } returns mockk() @@ -460,7 +456,6 @@ class DefaultTokenDetailsDeepLinkHandlerTest { walletBalanceFetcher.invoke( WalletBalanceFetcher.Params( userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = true ) ) } @@ -483,7 +478,6 @@ class DefaultTokenDetailsDeepLinkHandlerTest { analyticsEventHandler = analyticsEventHandler, getUserWalletUseCase = getUserWalletUseCase, walletBalanceFetcher = walletBalanceFetcher, - tangemPayFeatureToggles = tangemPayFeatureToggles, singleAccountListSupplier = singleAccountListSupplier, getSelectedWalletSyncUseCase = getSelectedWalletSync, ) diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 83c646bcc9..0f05b2b6c7 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -147,7 +147,6 @@ dependencies { implementation(projects.features.kyc.api) implementation(projects.features.tokenRecieve.api) implementation(projects.features.yieldSupply.api) - implementation(projects.features.tangempay.details.api) implementation(projects.features.feed.api) implementation(projects.features.promoBanners.api) implementation(projects.features.tangempay.main.api) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index da57b17d4e..6bc6f97a27 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -14,12 +14,13 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore -import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.apptheme.model.AppThemeMode +import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.* @@ -27,7 +28,6 @@ import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUse import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.repository.OnboardingRepository -import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.domain.qrscanning.models.ClassifiedQrContent import com.tangem.domain.qrscanning.models.QrResultSource import com.tangem.domain.qrscanning.models.QrSendTarget @@ -38,8 +38,6 @@ import com.tangem.domain.settings.* import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase import com.tangem.domain.walletconnect.WcPairService import com.tangem.domain.walletconnect.model.WcPairRequest -import com.tangem.domain.assetsdiscovery.usecase.StartAssetsDiscoveryUseCase -import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.domain.wallets.usecase.* import com.tangem.domain.yield.supply.usecase.YieldSupplyApyUpdateUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents @@ -62,15 +60,15 @@ import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSend import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedCallbacks import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import com.tangem.features.biometry.AskBiometryComponent +import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks -import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener import com.tangem.utils.Provider import com.tangem.utils.coroutines.* +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.* import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val TANGEM_PAY_UPDATE_INTERVAL = 60_000L @@ -108,7 +106,6 @@ internal class WalletModel @Inject constructor( private val userWalletsListRepository: UserWalletsListRepository, private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase, private val tangemPayOnboardingRepository: OnboardingRepository, - private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase, private val getAppThemeModeUseCase: GetAppThemeModeUseCase, private val trackingContextProxy: TrackingContextProxy, private val singleAccountListSupplier: SingleAccountListSupplier, @@ -121,7 +118,6 @@ internal class WalletModel @Inject constructor( private val wcPairService: WcPairService, private val resolveQrSendTargetsUseCase: ResolveQrSendTargetsUseCase, private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, - private val tangemPayFeatureToggles: TangemPayFeatureToggles, private val uiMessageSender: UiMessageSender, private val hotWalletFeatureToggles: HotWalletFeatureToggles, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, @@ -436,17 +432,14 @@ internal class WalletModel @Inject constructor( if (isShouldLaunchPeriodicUpdate) { updateTangemPayJobHolder.cancel() modelScope.launch { - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) while (isActive) { delay(TANGEM_PAY_UPDATE_INTERVAL) - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } }.saveIn(updateTangemPayJobHolder) } else { // Don't refresh customer info periodically if the card was already issued, only update on swipe to refresh - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } }.launchIn(modelScope) @@ -555,7 +548,6 @@ internal class WalletModel @Inject constructor( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) @@ -602,7 +594,6 @@ internal class WalletModel @Inject constructor( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) } @@ -624,7 +615,6 @@ internal class WalletModel @Inject constructor( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) } @@ -639,7 +629,6 @@ internal class WalletModel @Inject constructor( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) @@ -701,7 +690,6 @@ internal class WalletModel @Inject constructor( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt index efa8fe4574..5f01821a79 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/TangemPayClickIntents.kt @@ -22,11 +22,10 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.pay.TangemPayEligibilityManager -import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase -import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig @@ -72,7 +71,6 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( private val produceInitialDataTangemPay: ProduceTangemPayInitialDataUseCase, private val getWalletMetainfoUseCase: GetWalletMetaInfoUseCase, private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase, - private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase, private val tangemPayOnboardingRepository: OnboardingRepository, private val tangemPayEligibilityManager: TangemPayEligibilityManager, private val uiMessageSender: UiMessageSender, @@ -85,7 +83,6 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( if (!onboardingRepository.isTangemPayInitialDataProduced(userWalletId)) { return } - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } @@ -100,7 +97,6 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( modelScope.launch { produceInitialDataTangemPay.invoke(userWallet.walletId) .onRight { - tangemPayMainScreenCustomerInfoUseCase.fetch(userWallet.walletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWallet.walletId)) } .onLeft { @@ -277,7 +273,6 @@ internal class TangemPayClickIntentsImplementor @Inject constructor( modelScope.launch { tangemPayOnboardingRepository.disableTangemPay(userWalletId) .onRight { - tangemPayMainScreenCustomerInfoUseCase.fetch(userWalletId) paymentAccountStatusFetcher.invoke(PaymentAccountStatusFetcher.Params(userWalletId)) } .onLeft { uiMessageSender.send(ToastMessage(resourceReference(R.string.common_something_went_wrong))) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt index bbdd063032..c8d1027107 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/preview/WalletScreenPreviewDataLegacy.kt @@ -217,15 +217,8 @@ internal object WalletScreenPreviewDataLegacy { isFlickering = false, onItemClick = { }, ), - tangemPayState = TangemPayState.Card( - lastFourDigits = stringReference("*1234"), - balanceText = stringReference("$10"), - balanceSymbol = stringReference("USDC"), - onClick = {}, - ), type = WalletType.Cold, tangemPayMainUM = TangemPayMainUM.Empty, - isTangemPayRefactorEnabled = false, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt index a54705233c..beb32f4027 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/analytics/utils/WalletTangemPayAnalyticsEventSender.kt @@ -2,9 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.analytics.utils import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped -import com.tangem.domain.models.kyc.KycStatus -import com.tangem.domain.pay.model.MainScreenCustomerInfo -import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider import javax.inject.Inject @@ -15,26 +13,23 @@ internal class WalletTangemPayAnalyticsEventSender @Inject constructor( private val screenLifecycleProvider: ScreenLifecycleProvider, ) { - private val sentEvents = mutableSetOf() + private val sentEvents = mutableSetOf() - fun send(customerInfo: MainScreenCustomerInfo) { + fun send(statusValue: PaymentAccountStatusValue) { if (screenLifecycleProvider.isBackgroundState.value) return - val cardInfo = customerInfo.info.cardInfo - val productInstance = customerInfo.info.productInstance - - // TODO: TangemPay refactor analytics - // when statement copied from TangemPayUpdateInfoStateTransformer. Be careful when editing - val event = when { - // ignore cancelled state on analytics - customerInfo.orderStatus == OrderStatus.CANCELED -> return - // ignore kyc not approved state on analytics - customerInfo.info.kycStatus != KycStatus.APPROVED -> return - cardInfo != null && productInstance != null -> return - else -> TangemPayAnalyticsEvents.IssuingBannerDisplayed() + val event = when (statusValue) { + is PaymentAccountStatusValue.IssuingCard -> TangemPayAnalyticsEvents.IssuingBannerDisplayed() + PaymentAccountStatusValue.Empty, + is PaymentAccountStatusValue.Error, + is PaymentAccountStatusValue.Loaded, + PaymentAccountStatusValue.Loading, + PaymentAccountStatusValue.NotCreated, + is PaymentAccountStatusValue.UnderReview, + -> return } - if (sentEvents.add(event)) { + if (sentEvents.add(event.id)) { analyticsEventHandler.send(event) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletContentFetcher.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletContentFetcher.kt index b4cae53df1..c6b203e64d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletContentFetcher.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/WalletContentFetcher.kt @@ -2,16 +2,15 @@ package com.tangem.feature.wallet.presentation.wallet.domain import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.wallet.WalletBalanceFetcher -import com.tangem.features.tangempay.TangemPayFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveInAndJoin +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import com.tangem.utils.logging.TangemLogger import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -28,7 +27,6 @@ import javax.inject.Singleton internal class WalletContentFetcher @Inject constructor( private val walletBalanceFetcher: WalletBalanceFetcher, private val dispatchers: CoroutineDispatcherProvider, - private val tangemPayFeatureToggles: TangemPayFeatureToggles, ) { private val fetchingJobMap = ConcurrentHashMap() @@ -66,12 +64,8 @@ internal class WalletContentFetcher @Inject constructor( TangemLogger.d("Start fetching for $userWalletId") val maybeResult = launch { - walletBalanceFetcher( - params = WalletBalanceFetcher.Params( - userWalletId = userWalletId, - isPaymentAccountRefactorEnabled = tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled, - ), - ).onLeft { TangemLogger.e("Error", it) } + walletBalanceFetcher(params = WalletBalanceFetcher.Params(userWalletId = userWalletId)) + .onLeft { TangemLogger.e("Error", it) } } .saveInAndJoin(jobHolder) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TangemPayState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TangemPayState.kt deleted file mode 100644 index 4eae554a24..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/TangemPayState.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.model - -import androidx.annotation.DrawableRes -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.extensions.TextReference - -@Immutable -internal sealed class TangemPayState { - - object Empty : TangemPayState() - - data object Loading : TangemPayState() - - data class OnboardingBanner( - val onClick: () -> Unit, - val closeOnClick: () -> Unit, - ) : TangemPayState() - - data class Progress( - val title: TextReference, - val description: TextReference, - val buttonText: TextReference, - @DrawableRes val iconRes: Int, - val onButtonClick: () -> Unit, - val showProgress: Boolean = false, - ) : TangemPayState() - - data class FailedIssue( - val title: TextReference, - val description: TextReference, - @DrawableRes val iconRes: Int, - val onButtonClick: () -> Unit, - ) : TangemPayState() - - data class Card( - val lastFourDigits: TextReference, - val balanceText: TextReference, - val balanceSymbol: TextReference, - val onClick: () -> Unit, - ) : TangemPayState() - - data class RefreshNeeded( - val notification: WalletNotification, - ) : TangemPayState() - - data class TemporaryUnavailable(val notification: WalletNotification) : TangemPayState() - - data object ExposedDevice : TangemPayState() -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt index 82b4da90cc..6ee912f8fc 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletState.kt @@ -24,9 +24,7 @@ internal sealed interface WalletState : WalletStateHolder { abstract val tokensListState: WalletTokensListState abstract val nftState: WalletNFTItemUM abstract val type: WalletType - abstract val tangemPayState: TangemPayState abstract val tangemPayMainUM: TangemPayMainUM - abstract val isTangemPayRefactorEnabled: Boolean // TANGEM_PAY_ACCOUNTS_REFACTOR_ENABLED abstract val assetsDiscoveryProgressUM: AssetsDiscoveryProgressUM data class Content( @@ -38,9 +36,7 @@ internal sealed interface WalletState : WalletStateHolder { override val tokensListState: WalletTokensListState, override val nftState: WalletNFTItemUM, override val type: WalletType, - override val tangemPayState: TangemPayState, override val tangemPayMainUM: TangemPayMainUM, - override val isTangemPayRefactorEnabled: Boolean, override val assetsDiscoveryProgressUM: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle, ) : MultiCurrency() @@ -60,9 +56,7 @@ internal sealed interface WalletState : WalletStateHolder { override val tokensListState = WalletTokensListState.ContentState.Locked override val nftState: WalletNFTItemUM = WalletNFTItemUM.Hidden - override val tangemPayState: TangemPayState = TangemPayState.Empty override val tangemPayMainUM: TangemPayMainUM = TangemPayMainUM.Empty - override val isTangemPayRefactorEnabled: Boolean = false override val assetsDiscoveryProgressUM: AssetsDiscoveryProgressUM = AssetsDiscoveryProgressUM.Idle } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt index 7cba602ffb..ec3d6943be 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/AddWalletTransformer.kt @@ -13,7 +13,6 @@ internal class AddWalletTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { @@ -21,7 +20,6 @@ internal class AddWalletTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt index fe74c26c2b..a9774d2e2d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/InitializeWalletsTransformer.kt @@ -27,7 +27,6 @@ internal class InitializeWalletsTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { @@ -35,7 +34,6 @@ internal class InitializeWalletsTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeNewWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeNewWalletTransformer.kt index ed1b3a1b10..0c1b198d0c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeNewWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeNewWalletTransformer.kt @@ -24,7 +24,6 @@ internal class ReinitializeNewWalletTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { @@ -32,7 +31,6 @@ internal class ReinitializeNewWalletTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt index 00a8977b27..6dab085776 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/ReinitializeWalletTransformer.kt @@ -20,7 +20,6 @@ internal class ReinitializeWalletTransformer( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletStateTransformer(userWalletId = userWallet.walletId) { private val walletLoadingStateFactory by lazy { @@ -28,7 +27,6 @@ internal class ReinitializeWalletTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayExposedDeviceTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayExposedDeviceTransformer.kt deleted file mode 100644 index cd344f9f4f..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayExposedDeviceTransformer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayExposedDeviceTransformer( - userWalletId: UserWalletId, -) : WalletStateTransformer(userWalletId) { - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = TangemPayState.ExposedDevice) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHiddenStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHiddenStateTransformer.kt deleted file mode 100644 index bbb5035c07..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHiddenStateTransformer.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayHiddenStateTransformer( - userWalletId: UserWalletId, -) : WalletStateTransformer(userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = TangemPayState.Empty) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHideOnboardingStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHideOnboardingStateTransformer.kt index 9997dc0bcb..a25a960e0b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHideOnboardingStateTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayHideOnboardingStateTransformer.kt @@ -1,7 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM import com.tangem.features.tangempay.entity.TangemPayMainUM @@ -12,7 +11,7 @@ internal class TangemPayHideOnboardingStateTransformer( override fun transform(prevState: WalletState): WalletState { return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = TangemPayState.Empty, tangemPayMainUM = TangemPayMainUM.Empty) + prevState.copy(tangemPayMainUM = TangemPayMainUM.Empty) } else { prevState } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayLoadingStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayLoadingStateTransformer.kt deleted file mode 100644 index 6404796e7d..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayLoadingStateTransformer.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayLoadingStateTransformer(userWalletId: UserWalletId) : WalletStateTransformer(userWalletId) { - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = TangemPayState.Loading) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayOnboardingBannerStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayOnboardingBannerStateTransformer.kt deleted file mode 100644 index 4659e5f486..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayOnboardingBannerStateTransformer.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayOnboardingBannerStateTransformer( - userWalletId: UserWalletId, - private val onClick: (UserWalletId) -> Unit, - private val closeOnClick: (UserWalletId) -> Unit, -) : WalletStateTransformer(userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy( - tangemPayState = TangemPayState.OnboardingBanner( - onClick = { onClick(userWalletId) }, - closeOnClick = { closeOnClick(userWalletId) }, - ), - ) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshNeededStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshNeededStateTransformer.kt deleted file mode 100644 index b8673b8774..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshNeededStateTransformer.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.impl.R -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification.Warning.TangemPayRefreshNeeded -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayRefreshNeededStateTransformer( - userWalletId: UserWalletId, - private val userWallet: UserWallet, - private val onRefreshClick: () -> Unit, -) : WalletStateTransformer(userWalletId = userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - val tangemPayState = TangemPayState.RefreshNeeded( - notification = TangemPayRefreshNeeded( - buttonText = when (userWallet) { - is UserWallet.Cold -> resourceReference(id = R.string.home_button_scan) - is UserWallet.Hot -> resourceReference(id = R.string.tangempay_sync_needed_restore_access) - }, - onRefreshClick = onRefreshClick, - shouldShowProgress = false, - ), - ) - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = tangemPayState) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshShowProgressTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshShowProgressTransformer.kt index 17ff78b37f..77158bdb99 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshShowProgressTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayRefreshShowProgressTransformer.kt @@ -1,7 +1,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM @@ -14,9 +13,6 @@ internal class TangemPayRefreshShowProgressTransformer( override fun transform(prevState: WalletState): WalletState { val multiContentState = prevState as? WalletState.MultiCurrency.Content ?: return prevState - val refreshNeededState = multiContentState.tangemPayState as? TangemPayState.RefreshNeeded ?: return prevState - val refreshNotification = - refreshNeededState.notification as? WalletNotification.Warning.TangemPayRefreshNeeded ?: return prevState val newWarnings = prevState.warnings.map { warning -> if (warning is WalletNotification.Warning.TangemPayRefreshNeeded) { warning.copy(shouldShowProgress = shouldShowProgress) @@ -25,12 +21,7 @@ internal class TangemPayRefreshShowProgressTransformer( } } - return multiContentState.copy( - tangemPayState = refreshNeededState.copy( - notification = refreshNotification.copy(shouldShowProgress = shouldShowProgress), - ), - warnings = newWarnings.toImmutableList(), - ) + return multiContentState.copy(warnings = newWarnings.toImmutableList()) } override fun transform(walletUM: WalletUM): WalletUM { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUnavailableStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUnavailableStateTransformer.kt deleted file mode 100644 index 5b2a7765a9..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUnavailableStateTransformer.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM - -internal class TangemPayUnavailableStateTransformer( - userWalletId: UserWalletId, -) : WalletStateTransformer(userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy( - tangemPayState = TangemPayState.TemporaryUnavailable( - notification = WalletNotification.Warning.TangemPayUnreachable, - ), - ) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt deleted file mode 100644 index 238c8a36f1..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/TangemPayUpdateInfoStateTransformer.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.state.transformers - -import com.tangem.common.ui.R -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.domain.models.kyc.KycStatus -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.TangemPayDetailsConfig -import com.tangem.domain.pay.model.CustomerInfo.CardInfo -import com.tangem.domain.pay.model.CustomerInfo.ProductInstance -import com.tangem.domain.pay.model.MainScreenCustomerInfo -import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.feature.wallet.child.wallet.model.intents.TangemPayIntents -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM -import java.util.Currency - -/** - * Hardcode Polygon chain id only for F&F. - * Later chain id will be fetched from BFF. - */ -private const val POLYGON_CHAIN_ID = 137 - -internal class TangemPayUpdateInfoStateTransformer( - userWalletId: UserWalletId, - private val value: MainScreenCustomerInfo, - private val cardFrozenState: TangemPayCardFrozenState, - private val tangemPayClickIntents: TangemPayIntents, -) : WalletStateTransformer(userWalletId = userWalletId) { - - override fun transform(prevState: WalletState): WalletState { - val tangemPayState = createInitialState() - return if (prevState is WalletState.MultiCurrency.Content) { - prevState.copy(tangemPayState = tangemPayState) - } else { - prevState - } - } - - override fun transform(walletUM: WalletUM): WalletUM { - return walletUM // todo redesign main - } - - private fun createInitialState(): TangemPayState { - val cardInfo = value.info.cardInfo - val productInstance = value.info.productInstance - val customerId = value.info.customerId ?: "Unknown" - - // when statement copied to WalletTangemPayAnalyticsEventSender. Be careful when editing. - return when { - value.info.kycStatus != KycStatus.APPROVED && !value.info.customerId.isNullOrEmpty() -> - createKycInProgressState(kycStatus = value.info.kycStatus, customerId = customerId) - value.orderStatus == OrderStatus.CANCELED -> createCancelledState(customerId) - cardInfo != null && productInstance != null && value.orderStatus == OrderStatus.COMPLETED -> - getCardInfoState(customerId, cardInfo, productInstance) - else -> createIssueProgressState() - } - } - - private fun getCardInfoState( - customerId: String, - cardInfo: CardInfo, - productInstance: ProductInstance, - ): TangemPayState = TangemPayState.Card( - lastFourDigits = TextReference.Str("*${cardInfo.lastFourDigits}"), - balanceText = TextReference.Str(getBalanceText(cardInfo)), - balanceSymbol = stringReference("USDC"), // TODO hardcode for now - onClick = { - tangemPayClickIntents.openDetails( - userWalletId, - TangemPayDetailsConfig( - customerId = customerId, - cardId = productInstance.cardId, - isPinSet = cardInfo.isPinSet, - cardFrozenState = cardFrozenState, - cardNumberEnd = cardInfo.lastFourDigits, - chainId = POLYGON_CHAIN_ID, - displayName = productInstance.displayName, - ), - ) - }, - ) - - private fun getBalanceText(cardInfo: CardInfo): String { - val currency = Currency.getInstance(cardInfo.currencyCode) - return cardInfo.balance.format { - fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol) - } - } - - private fun createKycInProgressState(kycStatus: KycStatus, customerId: String): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = when (kycStatus) { - KycStatus.REJECTED -> TextReference.Res(R.string.tangempay_kyc_has_failed) - else -> TextReference.Res(R.string.tangempay_kyc_in_progress) - }, - buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), - iconRes = R.drawable.ic_promo_kyc_36, - onButtonClick = { - when (kycStatus) { - KycStatus.REJECTED -> tangemPayClickIntents.onKycRejectedClicked( - userWalletId = userWalletId, - customerId = customerId, - ) - else -> tangemPayClickIntents.onKycProgressClicked(userWalletId) - } - }, - ) - - private fun createIssueProgressState(): TangemPayState = Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_issuing_your_card), - buttonText = TextReference.EMPTY, - iconRes = R.drawable.ic_tangem_pay_promo_card_36, - onButtonClick = tangemPayClickIntents::onIssuingCardClicked, - showProgress = true, - ) - - private fun createCancelledState(customerId: String): TangemPayState = TangemPayState.FailedIssue( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_failed_to_issue_card), - iconRes = R.drawable.ic_alert_24, - onButtonClick = { tangemPayClickIntents.onIssuingFailedClicked(customerId) }, - ) -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt index 6e44868ca7..d80feaec8e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/UnlockWalletTransformer.kt @@ -9,16 +9,15 @@ import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenSta import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletLoadingStateFactory +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList -import com.tangem.utils.logging.TangemLogger internal class UnlockWalletTransformer( private val unlockedWallets: List, private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) : WalletScreenStateTransformer { private val walletLoadingStateFactory by lazy { @@ -26,7 +25,6 @@ internal class UnlockWalletTransformer( clickIntents = clickIntents, walletImageResolver = walletImageResolver, getWalletIconUseCase = getWalletIconUseCase, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt index 1b1be5d304..1140b80dd0 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/utils/WalletLoadingStateFactory.kt @@ -34,7 +34,6 @@ internal class WalletLoadingStateFactory( private val clickIntents: WalletClickIntents, private val walletImageResolver: WalletImageResolver, private val getWalletIconUseCase: GetWalletIconUseCase, - private val isTangemPayRefactorEnabled: Boolean, ) { fun create(userWallet: UserWallet): WalletState { @@ -83,9 +82,7 @@ internal class WalletLoadingStateFactory( tokensListState = WalletTokensListState.ContentState.Loading, nftState = WalletNFTItemUM.Hidden, type = WalletType.Hot, - tangemPayState = TangemPayState.Empty, tangemPayMainUM = TangemPayMainUM.Empty, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } @@ -99,9 +96,7 @@ internal class WalletLoadingStateFactory( tokensListState = WalletTokensListState.ContentState.Loading, nftState = WalletNFTItemUM.Hidden, type = WalletType.Cold, - tangemPayState = TangemPayState.Empty, tangemPayMainUM = TangemPayMainUM.Empty, - isTangemPayRefactorEnabled = isTangemPayRefactorEnabled, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt index a800e1a2a7..e58aa664f5 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt @@ -1,122 +1,38 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.MainCustomerInfoContentState -import com.tangem.domain.pay.model.MainScreenCustomerInfo -import com.tangem.domain.pay.model.TangemPayCustomerInfoError -import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.TangemPayWithdrawRepository -import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase -import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletTangemPayAnalyticsEventSender -import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController -import com.tangem.feature.wallet.presentation.wallet.state.transformers.* import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger -@Suppress("LongParameterList") internal class TangemPayMainSubscriber @AssistedInject constructor( @Assisted private val userWallet: UserWallet, - private val stateController: WalletStateController, - private val clickIntents: WalletClickIntents, - private val cardDetailsRepository: TangemPayCardDetailsRepository, - private val tangemPayMainScreenCustomerInfoUseCase: TangemPayMainScreenCustomerInfoUseCase, private val tangemPayWithdrawRepository: TangemPayWithdrawRepository, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val analytics: WalletTangemPayAnalyticsEventSender, ) : WalletSubscriber() { override fun create(coroutineScope: CoroutineScope): Flow<*> { coroutineScope.launch { + // TODO: Doston move this logic to proper place(e.g. WalletBalanceFetcher) tangemPayWithdrawRepository.pollWithdrawOrdersIfNeeds(userWallet) } - return subscribeOnTangemPayInfoUpdates() + subscribeToStatus(coroutineScope) + return emptyFlow() } - private fun subscribeOnTangemPayInfoUpdates(): Flow<*> { - return tangemPayMainScreenCustomerInfoUseCase(userWalletId = userWallet.walletId) + private fun subscribeToStatus(coroutineScope: CoroutineScope) { + paymentAccountStatusSupplier.invoke(userWalletId = userWallet.walletId) + .map { it.value } .distinctUntilChanged() - .onEach { mainInfoData -> - val userWalletId = userWallet.walletId - mainInfoData.onLeft { tangemPayError -> - when (tangemPayError) { - TangemPayCustomerInfoError.RefreshNeededError -> { - stateController.update( - transformer = TangemPayRefreshNeededStateTransformer( - userWalletId = userWalletId, - userWallet = userWallet, - onRefreshClick = { clickIntents.onRefreshPayToken(userWallet) }, - ), - ) - } - TangemPayCustomerInfoError.UnavailableError -> { - stateController.update( - transformer = TangemPayUnavailableStateTransformer(userWalletId), - ) - } - TangemPayCustomerInfoError.ExposedDeviceError -> { - stateController.update(TangemPayExposedDeviceTransformer(userWalletId)) - } - TangemPayCustomerInfoError.DeactivatedError -> { - stateController.update( - transformer = TangemPayHiddenStateTransformer(userWalletId), - ) - } - TangemPayCustomerInfoError.UnknownError -> { - // hide TangemPay block - TangemLogger.e("Failed when loading main screen TangemPay info: $tangemPayError") - stateController.update( - transformer = TangemPayHiddenStateTransformer(userWalletId), - ) - } - } - }.onRight { contentState -> handleContentState(state = contentState) } - } - } - - private suspend fun handleContentState(state: MainCustomerInfoContentState) { - val userWalletId = userWallet.walletId - when (state) { - MainCustomerInfoContentState.Loading -> stateController.update( - transformer = TangemPayLoadingStateTransformer(userWalletId), - ) - is MainCustomerInfoContentState.Content -> { - updateTangemPay(data = state.info, userWalletId = userWalletId) - analytics.send(customerInfo = state.info) - } - is MainCustomerInfoContentState.OnboardingBanner -> stateController.update( - transformer = TangemPayOnboardingBannerStateTransformer( - userWalletId = userWalletId, - onClick = clickIntents::onOnboardingBannerClick, - closeOnClick = clickIntents::onOnboardingBannerCloseClick, - ), - ) - is MainCustomerInfoContentState.Empty -> stateController.update( - transformer = TangemPayHiddenStateTransformer(userWalletId), - ) - } - } - - private suspend fun updateTangemPay(data: MainScreenCustomerInfo, userWalletId: UserWalletId) { - val cardFrozenState = - data.info.productInstance?.cardId?.let { cardDetailsRepository.cardFrozenStateSync(it) } - ?: TangemPayCardFrozenState.Unfrozen - stateController.update( - transformer = TangemPayUpdateInfoStateTransformer( - userWalletId = userWalletId, - value = data, - cardFrozenState = cardFrozenState, - tangemPayClickIntents = clickIntents, - ), - ) + .onEach(analytics::send) + .launchIn(coroutineScope) } @AssistedFactory diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 1dcf79012d..23de72403e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -82,7 +82,6 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.common.* import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.nftCollections import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.organizeTokensButton import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.marketPriceBlock -import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.TangemPayMainScreenBlock import com.tangem.feature.wallet.presentation.wallet.ui.utils.changeWalletAnimator import com.tangem.features.tangempay.component.TangemPayMainBlockComponent import com.tangem.features.tangempay.entity.TangemPayMainUM @@ -752,14 +751,8 @@ internal fun LazyListScope.tangemPayItem( ) { if (state !is WalletState.MultiCurrency) return - if (state.isTangemPayRefactorEnabled) { - with(tangemPayComponent) { - tangemPayMainContent(modifier = modifier, state = state.tangemPayMainUM, isBalanceHidden = isHidingMode) - } - } else { - item(key = "TangemPayMainScreenBlock", contentType = state.tangemPayState::class.java) { - TangemPayMainScreenBlock(modifier = modifier, state = state.tangemPayState, isBalanceHidden = isHidingMode) - } + with(tangemPayComponent) { + tangemPayMainContent(modifier = modifier, state = state.tangemPayMainUM, isBalanceHidden = isHidingMode) } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/singlecurrency/TangemPayCardMainBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/singlecurrency/TangemPayCardMainBlock.kt deleted file mode 100644 index c155df8fd4..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/singlecurrency/TangemPayCardMainBlock.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency - -import android.content.res.Configuration -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.tangem.common.ui.R -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.orMaskWithStars -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.TangemPayMainScreenBlock - -@Composable -internal fun TangemPayCardMainBlock( - state: TangemPayState.Card, - isBalanceHidden: Boolean, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier.fillMaxWidth(), - shape = TangemTheme.shapes.roundedCornersXMedium, - color = TangemTheme.colors.background.primary, - onClick = state.onClick, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .padding(horizontal = 12.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Image( - painter = painterResource(R.drawable.img_visa_36), - contentDescription = null, - modifier = Modifier.size(36.dp), - ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = stringResourceSafe(R.string.tangempay_payment_account), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.primary1, - ) - Text( - text = state.lastFourDigits.resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - } - Column( - modifier = Modifier.fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(2.dp), - horizontalAlignment = Alignment.End, - ) { - Text( - text = state.balanceText.resolveReference().orMaskWithStars(isBalanceHidden), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.primary1, - textAlign = TextAlign.End, - ) - Text( - text = state.balanceSymbol.resolveReference(), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - textAlign = TextAlign.End, - ) - } - } - } -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemPayCardMainBlockPreview() { - TangemThemePreview { - TangemPayMainScreenBlock( - TangemPayState.Card( - lastFourDigits = TextReference.Str("*1234"), - balanceText = TextReference.Str("$ 0.00"), - balanceSymbol = TextReference.Str("USDC"), - onClick = {}, - ), - isBalanceHidden = false, - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayExposedDeviceState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayExposedDeviceState.kt deleted file mode 100644 index 6ba66da79f..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayExposedDeviceState.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import com.tangem.core.ui.R -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme - -private const val DISABLED_ALPHA = 0.6F - -@Composable -internal fun TangemPayExposedDeviceState(modifier: Modifier = Modifier) { - BlockCard( - modifier = modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary) - .alpha(DISABLED_ALPHA), - enabled = false, - onClick = {}, - ) { - InputRowImageBase( - modifier = Modifier - .padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = resourceReference(R.string.tangempay_payment_account), - caption = resourceReference(R.string.tangem_pay_rooted_device_subtitle), - subtitleColor = TangemTheme.colors.text.primary1, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = R.drawable.img_visa_36, - endIconTint = TangemTheme.colors.icon.warning, - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayFailedIssueState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayFailedIssueState.kt deleted file mode 100644 index df4c7bfafd..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayFailedIssueState.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState - -@Composable -internal fun TangemPayFailedIssueState(state: TangemPayState.FailedIssue, modifier: Modifier = Modifier) { - BlockCard( - modifier = modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary), - onClick = state.onButtonClick, - ) { - InputRowImageBase( - modifier = Modifier - .padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = state.title, - caption = state.description, - subtitleColor = TangemTheme.colors.text.primary1, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = com.tangem.core.ui.R.drawable.img_visa_36, - iconEndRes = state.iconRes, - endIconTint = TangemTheme.colors.icon.warning, - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayLoadingScreenBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayLoadingScreenBlock.kt deleted file mode 100644 index a2dbe13375..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayLoadingScreenBlock.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.tangem.core.ui.components.CircleShimmer -import com.tangem.core.ui.components.RectangleShimmer -import com.tangem.core.ui.components.SpacerWMax -import com.tangem.core.ui.res.TangemTheme - -@Composable -internal fun TangemPayLoadingScreenBlock(modifier: Modifier = Modifier) { - Row( - modifier = modifier - .fillMaxWidth() - .background(color = TangemTheme.colors.background.primary, shape = TangemTheme.shapes.roundedCornersXMedium) - .padding(horizontal = 12.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - CircleShimmer(modifier = Modifier.size(36.dp)) - Column( - modifier = Modifier.padding(start = 12.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - RectangleShimmer(modifier = Modifier.padding(vertical = 4.dp).sizeIn(minWidth = 70.dp, minHeight = 12.dp)) - RectangleShimmer(modifier = Modifier.padding(vertical = 2.dp).sizeIn(minWidth = 52.dp, minHeight = 12.dp)) - } - SpacerWMax() - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - RectangleShimmer(modifier = Modifier.padding(vertical = 4.dp).sizeIn(minWidth = 40.dp, minHeight = 12.dp)) - RectangleShimmer(modifier = Modifier.padding(vertical = 2.dp).sizeIn(minWidth = 40.dp, minHeight = 12.dp)) - } - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayMainScreenBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayMainScreenBlock.kt deleted file mode 100644 index 862b6c770d..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayMainScreenBlock.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import android.content.res.Configuration -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.common.ui.R -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState.Progress -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification.Warning.TangemPayRefreshNeeded -import com.tangem.feature.wallet.presentation.wallet.ui.components.singlecurrency.TangemPayCardMainBlock - -@Composable -internal fun TangemPayMainScreenBlock(state: TangemPayState, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { - when (state) { - is Progress -> TangemPayProgressState(state, modifier) - is TangemPayState.Card -> TangemPayCardMainBlock(state, isBalanceHidden, modifier) - is TangemPayState.Empty -> Unit - is TangemPayState.RefreshNeeded -> TangemPayRefreshBlock(state, modifier) - is TangemPayState.TemporaryUnavailable -> TangemPayUnavailableBlock(state, modifier) - is TangemPayState.FailedIssue -> TangemPayFailedIssueState(state, modifier) - is TangemPayState.OnboardingBanner -> TangemPayOnboardingBanner(state, modifier) - is TangemPayState.ExposedDevice -> TangemPayExposedDeviceState(modifier) - is TangemPayState.Loading -> TangemPayLoadingScreenBlock(modifier) - } -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemPayMainScreenBlockPreview() { - TangemThemePreview { - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - TangemPayMainScreenBlock(state = TangemPayState.Loading, isBalanceHidden = false) - TangemPayMainScreenBlock( - state = TangemPayState.RefreshNeeded( - TangemPayRefreshNeeded( - buttonText = resourceReference(id = R.string.home_button_scan), - onRefreshClick = {}, - shouldShowProgress = false, - ), - ), - isBalanceHidden = false, - ) - TangemPayMainScreenBlock( - state = TangemPayState.TemporaryUnavailable(WalletNotification.Warning.TangemPayUnreachable), - isBalanceHidden = false, - ) - TangemPayMainScreenBlock( - state = TangemPayState.OnboardingBanner(onClick = {}, closeOnClick = {}), - isBalanceHidden = false, - ) - - TangemPayMainScreenBlock(state = TangemPayState.ExposedDevice, isBalanceHidden = false) - TangemPayMainScreenBlock( - state = TangemPayState.FailedIssue( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_failed_to_issue_card), - iconRes = R.drawable.ic_alert_24, - onButtonClick = { }, - ), - isBalanceHidden = false, - ) - - TangemPayMainScreenBlock( - Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_kyc_in_progress), - buttonText = TextReference.Res(R.string.tangempay_kyc_in_progress_notification_button), - iconRes = R.drawable.ic_promo_kyc_36, - onButtonClick = {}, - ), - isBalanceHidden = false, - ) - - TangemPayMainScreenBlock( - Progress( - title = TextReference.Res(R.string.tangempay_payment_account), - description = TextReference.Res(R.string.tangempay_issuing_your_card), - buttonText = TextReference.EMPTY, - iconRes = R.drawable.ic_tangem_pay_promo_card_36, - onButtonClick = {}, - showProgress = true, - ), - isBalanceHidden = false, - ) - - TangemPayMainScreenBlock( - TangemPayState.Card( - lastFourDigits = TextReference.Str("*1234"), - balanceText = TextReference.Str("$ 0.00"), - balanceSymbol = TextReference.Str("USDC"), - onClick = {}, - ), - isBalanceHidden = false, - ) - } - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayOnboardingBanner.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayOnboardingBanner.kt deleted file mode 100644 index d345eede90..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayOnboardingBanner.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import android.content.res.Configuration -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension -import com.tangem.core.ui.components.SpacerH -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.impl.R -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState - -private const val GRADIENT_START_COLOR = 0xFF252934 -private const val GRADIENT_END_COLOR = 0xFF12141E -private const val GRADIENT_OFFSET_X = 0f -private const val GRADIENT_OFFSET_Y = 80F -private const val GRADIENT_RADIUS = 200F - -@Composable -internal fun TangemPayOnboardingBanner(state: TangemPayState.OnboardingBanner, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxWidth() - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background( - brush = Brush.radialGradient( - colors = listOf(Color(GRADIENT_START_COLOR), Color(GRADIENT_END_COLOR)), - center = Offset(GRADIENT_OFFSET_X, GRADIENT_OFFSET_Y), - radius = GRADIENT_RADIUS, - ), - ) - .clickable(onClick = state.onClick), - ) { - ConstraintLayout( - modifier = Modifier.fillMaxWidth(), - ) { - val (image, text, close) = createRefs() - - Image( - painter = painterResource(R.drawable.ic_close_24), - contentDescription = null, - modifier = Modifier - .size(16.dp) - .clickable(onClick = state.closeOnClick) - .constrainAs(close) { - top.linkTo(parent.top, margin = 16.dp) - end.linkTo(parent.end, margin = 16.dp) - }, - colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive), - ) - - Column( - modifier = Modifier - .constrainAs(text) { - top.linkTo(parent.top) - start.linkTo(image.end, margin = 12.dp) - end.linkTo(close.start, margin = 12.dp) - width = Dimension.fillToConstraints - } - .padding(top = 16.dp, bottom = 16.dp), - ) { - Text( - text = stringResourceSafe(R.string.tangempay_onboarding_banner_title), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.constantWhite, - ) - - SpacerH(6.dp) - - Text( - text = stringResourceSafe(R.string.tangempay_onboarding_banner_description), - style = TangemTheme.typography.caption2, - color = TangemTheme.colors.text.tertiary, - ) - } - - Image( - painter = painterResource(R.drawable.img_tangem_pay_onboarding_banner), - contentDescription = null, - modifier = Modifier - .padding(top = 8.dp, start = 24.dp) - .constrainAs(image) { - start.linkTo(parent.start) - top.linkTo(text.top) - bottom.linkTo(text.bottom) - height = Dimension.fillToConstraints - }, - ) - } - } -} - -@Preview(showBackground = true) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun PreviewTangemOnboardingBanner() { - TangemThemePreview { - TangemPayOnboardingBanner( - TangemPayState.OnboardingBanner( - onClick = {}, - closeOnClick = {}, - ), - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayProgressState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayProgressState.kt deleted file mode 100644 index a73e842c3b..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayProgressState.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import com.tangem.core.ui.R -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.res.TangemTheme -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState - -@Composable -internal fun TangemPayProgressState(state: TangemPayState.Progress, modifier: Modifier = Modifier) { - BlockCard( - modifier = modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary), - onClick = state.onButtonClick, - ) { - InputRowImageBase( - modifier = Modifier - .padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = state.title, - caption = state.description, - subtitleColor = TangemTheme.colors.text.primary1, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = R.drawable.img_visa_36, - ) - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayRefreshBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayRefreshBlock.kt deleted file mode 100644 index 715a6168a2..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemPayRefreshBlock.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification.Warning.TangemPayRefreshNeeded - -@Composable -internal fun TangemPayRefreshBlock(state: TangemPayState.RefreshNeeded, modifier: Modifier = Modifier) { - Column(modifier) { - Notification( - config = state.notification.config, - iconTint = when (state.notification) { - is WalletNotification.Critical -> TangemTheme.colors.icon.warning - is WalletNotification.Informational -> TangemTheme.colors.icon.accent - is WalletNotification.RateApp -> TangemTheme.colors.icon.attention - is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 - is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention - else -> null - }, - ) - SpacerH12() - - BlockCard( - modifier = Modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary), - enabled = false, - ) { - InputRowImageBase( - modifier = Modifier.padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = resourceReference(R.string.tangempay_payment_account), - caption = resourceReference(R.string.tangempay_payment_account_sync_needed), - subtitleColor = TangemTheme.colors.text.tertiary, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = R.drawable.img_visa_36, - ) - } - } -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemPayRefreshBlockPreview() { - TangemThemePreview { - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - TangemPayRefreshBlock( - state = TangemPayState.RefreshNeeded( - TangemPayRefreshNeeded( - buttonText = resourceReference(id = R.string.tangempay_sync_needed_restore_access), - onRefreshClick = {}, - shouldShowProgress = true, - ), - ), - modifier = Modifier, - ) - } - } -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemUnavailableBlock.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemUnavailableBlock.kt deleted file mode 100644 index a8628de815..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/visa/TangemUnavailableBlock.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.tangem.feature.wallet.presentation.wallet.ui.components.visa - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.block.BlockCard -import com.tangem.core.ui.components.inputrow.InputRowImageBase -import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.wallet.presentation.wallet.state.model.TangemPayState -import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification -import com.tangem.utils.StringsSigns.DASH_SIGN - -@Composable -internal fun TangemPayUnavailableBlock(state: TangemPayState.TemporaryUnavailable, modifier: Modifier = Modifier) { - Column(modifier) { - Notification( - config = state.notification.config, - iconTint = when (state.notification) { - is WalletNotification.Critical -> TangemTheme.colors.icon.warning - is WalletNotification.Informational -> TangemTheme.colors.icon.accent - is WalletNotification.RateApp -> TangemTheme.colors.icon.attention - is WalletNotification.UnlockWallets -> TangemTheme.colors.icon.primary1 - is WalletNotification.UsedOutdatedData -> TangemTheme.colors.text.attention - else -> null - }, - ) - SpacerH12() - - BlockCard( - modifier = Modifier - .clip(RoundedCornerShape(size = TangemTheme.dimens.radius14)) - .background(TangemTheme.colors.background.primary), - enabled = false, - ) { - InputRowImageBase( - modifier = Modifier.padding( - all = TangemTheme.dimens.spacing12, - ), - subtitle = resourceReference(R.string.tangempay_payment_account), - caption = TextReference.Str(DASH_SIGN), - subtitleColor = TangemTheme.colors.text.tertiary, - captionColor = TangemTheme.colors.text.tertiary, - iconResWebp = R.drawable.img_visa_36, - ) - } - } -} - -@Preview(showBackground = true) -@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun TangemPayUnavailableBlockPreview() { - TangemThemePreview { - Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8)) { - TangemPayUnavailableBlock( - state = TangemPayState.TemporaryUnavailable( - WalletNotification.Warning.TangemPayUnreachable, - ), - modifier = Modifier, - ) - } - } -} \ No newline at end of file From 0ae0d5895858ae39cfc19932b3fdc269b4b1df5f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Apr 2026 15:56:28 +0000 Subject: [PATCH 134/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 94aa5668ec..f191596235 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1494" +tangemBlockchainSdk = "develop-1498" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-603" +tangemCardSdk = "develop-614" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From f83c4b7af5ca51d8531550d3e6d4fd1982bcf2ea Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Apr 2026 00:12:16 -0700 Subject: [PATCH 135/206] Updated on 2026-08-14 --- .../main/res/drawable/ic_cloud_fill_16.xml | 9 ++++ .../tangempay/ui/TangemPayCardDetailsBlock.kt | 44 +++++++++---------- 2 files changed, 30 insertions(+), 23 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_cloud_fill_16.xml diff --git a/core/ui/src/main/res/drawable/ic_cloud_fill_16.xml b/core/ui/src/main/res/drawable/ic_cloud_fill_16.xml new file mode 100644 index 0000000000..fe6e3ff3f8 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_cloud_fill_16.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index b2509b7b49..d2feacdda6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -12,33 +12,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -60,6 +40,7 @@ import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameter import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import com.tangem.core.ui.components.SpacerW4 import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.buttons.common.TangemButton import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition @@ -149,6 +130,23 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif contentDescription = null, ) + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_cloud_fill_16), + tint = TangemTheme.colors.icon.constant, + contentDescription = null, + ) + SpacerW4() + Text( + text = stringResourceSafe(R.string.tangempay_digital_card), + style = TangemTheme.typography.subtitle2, + color = TangemTheme.colors.text.constantWhite, + ) + } + if (state.isActive) { ConstraintLayout( modifier = Modifier From 6a82ee1600cbadb8122e180861d954ff3b453216 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Apr 2026 09:22:33 +0100 Subject: [PATCH 136/206] Updated on 2026-08-14 --- features/details/impl/build.gradle.kts | 11 + .../details/utils/UserWalletSaverTest.kt | 337 ++++++++++++ .../DefaultAddressSyncComponent.kt | 2 - .../impl/model/OnboardingEntryModelTest.kt | 344 ++++++++++++ .../model/MultiWalletFinalizeModelTest.kt | 503 ++++++++++++++++++ .../model/OnboardingMultiWalletModelTest.kt | 454 ++++++++++++++++ .../v2/multiwallet/impl/model/UtilsTest.kt | 66 +++ 7 files changed, 1715 insertions(+), 2 deletions(-) create mode 100644 features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt create mode 100644 features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt create mode 100644 features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt create mode 100644 features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt create mode 100644 features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index e86fe43d33..f0cda1eba5 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.details.impl" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /* Project - API */ @@ -80,4 +84,11 @@ dependencies { implementation(deps.kotlin.immutable.collections) implementation(deps.arrow.core) implementation(deps.arrow.fx) + + /* Test */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt new file mode 100644 index 0000000000..60fa5c7682 --- /dev/null +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt @@ -0,0 +1,337 @@ +package com.tangem.features.details.utils + +import arrow.core.Either +import com.tangem.common.core.TangemError +import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.common.wallets.error.SaveWalletError +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.features.details.impl.R +import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class UserWalletSaverTest { + + private val scanCardProcessor: ScanCardProcessor = mockk() + private val saveWalletUseCase: SaveWalletUseCase = mockk() + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk() + private val messageSender: UiMessageSender = mockk(relaxUnitFun = true) + private val router: Router = mockk(relaxUnitFun = true) + private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles = mockk() + + private val scanResponse: ScanResponse = mockk() + private val userWalletId: UserWalletId = UserWalletId("011") + private val userWallet: UserWallet.Cold = mockk { + every { walletId } returns userWalletId + } + + @BeforeEach + fun setUp() { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + } + + @Test + fun `GIVEN onWalletNotCreated WHEN scanAndSaveUserWallet THEN no message AND no save`() = runTest { + mockScanCallback(callbackName = ON_WALLET_NOT_CREATED) + + createSaver().scanAndSaveUserWallet(this) + + verify(exactly = 0) { messageSender.send(any()) } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN disclaimerWillShow WHEN scanAndSaveUserWallet THEN router pop AND no save`() = runTest { + mockScanCallback(callbackName = DISCLAIMER_WILL_SHOW) + + createSaver().scanAndSaveUserWallet(this) + + verify { router.pop(onComplete = any()) } + verify(exactly = 0) { messageSender.send(any()) } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onCancel WHEN scanAndSaveUserWallet THEN no message AND no save`() = runTest { + mockScanCallback(callbackName = ON_CANCEL) + + createSaver().scanAndSaveUserWallet(this) + + verify(exactly = 0) { messageSender.send(any()) } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onFailure with messageResId WHEN scanAndSaveUserWallet THEN SnackbarMessage with resource is sent`() = + runTest { + val tangemError = mockk { + every { silent } returns false + every { messageResId } returns R.string.common_unknown_error + every { customMessage } returns "any" + } + mockScanFailure(tangemError) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)), + ) + } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onFailure without messageResId WHEN scanAndSaveUserWallet THEN SnackbarMessage with custom message`() = + runTest { + val tangemError = mockk { + every { silent } returns false + every { messageResId } returns null + every { customMessage } returns "Custom error" + } + mockScanFailure(tangemError) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = stringReference("Custom error")), + ) + } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onFailure silent WHEN scanAndSaveUserWallet THEN no message is sent`() = runTest { + val tangemError = mockk { + every { silent } returns true + every { messageResId } returns null + every { customMessage } returns "any" + } + mockScanFailure(tangemError) + + createSaver().scanAndSaveUserWallet(this) + + verify(exactly = 0) { messageSender.send(any()) } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onSuccess AND save success AND addressSync disabled WHEN scanAndSaveUserWallet THEN popTo Wallet`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns false + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Right(Unit) + + createSaver().scanAndSaveUserWallet(this) + + verify { router.popTo(routeClass = AppRoute.Wallet::class, onComplete = any()) } + verify(exactly = 0) { router.push(route = any(), onComplete = any()) } + verify(exactly = 0) { messageSender.send(any()) } + } + + @Test + fun `GIVEN onSuccess AND save success AND addressSync enabled WHEN scanAndSaveUserWallet THEN push Onboarding`() = + runTest { + every { onboardingV2FeatureToggles.isAddressSyncEnabled } returns true + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Right(Unit) + + createSaver().scanAndSaveUserWallet(this) + + verify { + router.push( + route = AppRoute.Onboarding( + scanResponse = scanResponse, + mode = AppRoute.Onboarding.Mode.AddressSync( + userWalletId = userWalletId, + isWalletStarted = true, + ), + ), + onComplete = any(), + ) + } + verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) } + } + + @Test + fun `GIVEN onSuccess AND createUserWallet returns null WHEN scanAndSaveUserWallet THEN unknown error message`() = + runTest { + mockScanSuccess(scanResponse) + mockBuilderReturns(null) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)), + ) + } + coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } + } + + @Test + fun `GIVEN onSuccess AND save WalletAlreadySaved WHEN scanAndSaveUserWallet THEN DialogMessage is sent`() = + runTest { + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Left( + SaveWalletError.WalletAlreadySaved(messageId = R.string.user_wallet_list_error_wallet_already_saved), + ) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = DialogMessage( + message = resourceReference(R.string.user_wallet_list_error_wallet_already_saved), + ), + ) + } + verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) } + verify(exactly = 0) { router.push(route = any(), onComplete = any()) } + } + + @Test + fun `GIVEN onSuccess AND save DataError with messageId WHEN scanAndSaveUserWallet THEN snackbar with resource`() = + runTest { + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Left( + SaveWalletError.DataError(messageId = R.string.common_unknown_error), + ) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)), + ) + } + verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) } + } + + @Test + fun `GIVEN onSuccess AND save DataError without messageId WHEN scanAndSaveUserWallet THEN unknown error snackbar`() = + runTest { + mockScanSuccess(scanResponse) + mockBuilderReturns(userWallet) + coEvery { saveWalletUseCase.invoke(userWallet, false, any()) } returns Either.Left( + SaveWalletError.DataError(messageId = null), + ) + + createSaver().scanAndSaveUserWallet(this) + + verify { + messageSender.send( + message = SnackbarMessage(message = resourceReference(R.string.common_unknown_error)), + ) + } + verify(exactly = 0) { router.popTo(routeClass = any(), onComplete = any()) } + } + + private fun mockScanCallback(callbackName: String) { + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any(), + ) + } coAnswers { + when (callbackName) { + ON_WALLET_NOT_CREATED -> arg Unit>(ON_WALLET_NOT_CREATED_INDEX).invoke() + DISCLAIMER_WILL_SHOW -> arg<() -> Unit>(DISCLAIMER_WILL_SHOW_INDEX).invoke() + ON_CANCEL -> arg Unit>(ON_CANCEL_INDEX).invoke() + } + } + } + + private fun mockScanFailure(tangemError: TangemError) { + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any(), + ) + } coAnswers { + arg Unit>(ON_FAILURE_INDEX).invoke(tangemError) + } + } + + private fun mockScanSuccess(scanResponse: ScanResponse) { + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = any(), + onWalletNotCreated = any(), + disclaimerWillShow = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any(), + ) + } coAnswers { + arg Unit>(ON_SUCCESS_INDEX).invoke(scanResponse) + } + } + + private fun mockBuilderReturns(userWallet: UserWallet.Cold?) { + val builder: ColdUserWalletBuilder = mockk { + every { build() } returns userWallet + } + every { coldUserWalletBuilderFactory.create(scanResponse = any()) } returns builder + } + + private fun createSaver(): UserWalletSaver { + return UserWalletSaver( + scanCardProcessor = scanCardProcessor, + saveWalletUseCase = saveWalletUseCase, + coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, + messageSender = messageSender, + router = router, + onboardingV2FeatureToggles = onboardingV2FeatureToggles, + ) + } + + private companion object { + const val ON_WALLET_NOT_CREATED = "onWalletNotCreated" + const val DISCLAIMER_WILL_SHOW = "disclaimerWillShow" + const val ON_CANCEL = "onCancel" + + const val ON_WALLET_NOT_CREATED_INDEX = 4 + const val DISCLAIMER_WILL_SHOW_INDEX = 5 + const val ON_CANCEL_INDEX = 6 + const val ON_FAILURE_INDEX = 7 + const val ON_SUCCESS_INDEX = 8 + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt index e5698414d1..564379e137 100644 --- a/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt +++ b/features/onboarding-v2/impl/src/main/kotlin/com/tangem/features/onboarding/v2/addresssync/DefaultAddressSyncComponent.kt @@ -2,7 +2,6 @@ package com.tangem.features.onboarding.v2.addresssync import androidx.activity.compose.BackHandler import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -117,7 +116,6 @@ internal class DefaultAddressSyncComponent( AddressSyncState.Loading -> AddressSyncLoading() is AddressSyncState.Success -> AddressSyncButtonScreen( state = state as AddressSyncState.Success, - modifier = Modifier.fillMaxSize(), onSyncClick = { model.onIntent(AddressSyncIntent.Sync) }, diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt new file mode 100644 index 0000000000..db8b3ae7df --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/entry/impl/model/OnboardingEntryModelTest.kt @@ -0,0 +1,344 @@ +package com.tangem.features.onboarding.v2.entry.impl.model + +import com.arkivanov.decompose.router.stack.StackNavigation +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog +import com.tangem.features.onboarding.v2.done.api.OnboardingDoneComponent +import com.tangem.features.onboarding.v2.entry.OnboardingEntryComponent +import com.tangem.features.onboarding.v2.entry.impl.routing.OnboardingRoute +import com.tangem.features.onboarding.v2.multiwallet.api.OnboardingMultiWalletComponent +import com.tangem.features.onboarding.v2.twin.api.OnboardingTwinComponent +import com.tangem.features.onboarding.v2.visa.impl.child.welcome.model.analytics.OnboardingVisaAnalyticsEvent +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import kotlin.reflect.KClass + +@OptIn(ExperimentalCoroutinesApi::class) +internal class OnboardingEntryModelTest { + + private val router: Router = mockk(relaxUnitFun = true) + private val tangemSdkManager: TangemSdkManager = mockk() + private val settingsRepository: SettingsRepository = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) + private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true) + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val paramsContainer: ParamsContainer = mockk() + + private val scanResponse: ScanResponse = mockk() + private val params: OnboardingEntryComponent.Params = mockk { + every { scanResponse } returns this@OnboardingEntryModelTest.scanResponse + } + + @BeforeEach + fun setUp() { + every { paramsContainer.require() } returns params + every { params.mode } returns OnboardingEntryComponent.Mode.Onboarding + every { scanResponse.productType } returns ProductType.Wallet + coEvery { tangemSdkManager.checkCanUseBiometry() } returns false + coEvery { settingsRepository.shouldShowAskBiometry() } returns false + } + + @ParameterizedTest + @MethodSource("provideStartRouteByProductType") + fun `GIVEN product type WHEN model is created THEN startRoute is of expected type`( + productType: ProductType, + expectedRouteClass: KClass, + ) = runTest { + every { scanResponse.productType } returns productType + every { params.mode } returns OnboardingEntryComponent.Mode.Onboarding + + val model = createModel(this) + + Assertions.assertTrue( + expectedRouteClass.isInstance(model.startRoute), + "Expected ${expectedRouteClass.simpleName} but got ${model.startRoute::class.simpleName}", + ) + } + + @ParameterizedTest + @MethodSource("provideWallet2ModeMappings") + fun `GIVEN Wallet2 AND entry mode WHEN model is created THEN multi-wallet mode is mapped`( + entryMode: OnboardingEntryComponent.Mode, + expectedMultiWalletMode: OnboardingMultiWalletComponent.Mode, + ) = runTest { + every { scanResponse.productType } returns ProductType.Wallet2 + every { params.mode } returns entryMode + + val model = createModel(this) + + val route = model.startRoute as OnboardingRoute.MultiWallet + Assertions.assertEquals(expectedMultiWalletMode, route.mode) + Assertions.assertEquals(true, route.withSeedPhraseFlow) + } + + @ParameterizedTest + @MethodSource("provideTwinsModeMappings") + fun `GIVEN Twins AND entry mode WHEN model is created THEN twin mode is mapped`( + entryMode: OnboardingEntryComponent.Mode, + expectedTwinMode: OnboardingTwinComponent.Params.Mode, + ) = runTest { + every { scanResponse.productType } returns ProductType.Twins + every { params.mode } returns entryMode + + val model = createModel(this) + + val route = model.startRoute as OnboardingRoute.Twins + Assertions.assertEquals(expectedTwinMode, route.mode) + } + + @Test + fun `GIVEN Wallet WHEN model is created THEN withSeedPhraseFlow is false`() = runTest { + every { scanResponse.productType } returns ProductType.Wallet + every { params.mode } returns OnboardingEntryComponent.Mode.Onboarding + + val model = createModel(this) + + val route = model.startRoute as OnboardingRoute.MultiWallet + Assertions.assertEquals(false, route.withSeedPhraseFlow) + } + + @Test + fun `GIVEN biometry available AND should ask WHEN onManageTokensDone THEN AskBiometry replaces stack`() = runTest { + coEvery { tangemSdkManager.checkCanUseBiometry() } returns true + coEvery { settingsRepository.shouldShowAskBiometry() } returns true + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onManageTokensDone() + advanceUntilIdle() + + Assertions.assertEquals(1, stack.size) + Assertions.assertTrue(stack.first() is OnboardingRoute.AskBiometry) + } + + @Test + fun `GIVEN biometry not available WHEN onManageTokensDone THEN Done WalletCreated replaces stack`() = runTest { + coEvery { tangemSdkManager.checkCanUseBiometry() } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onManageTokensDone() + advanceUntilIdle() + + Assertions.assertEquals(1, stack.size) + val route = stack.first() + Assertions.assertTrue(route is OnboardingRoute.Done) + Assertions.assertEquals(OnboardingDoneComponent.Mode.WalletCreated, (route as OnboardingRoute.Done).mode) + } + + @Test + fun `GIVEN biometry available AND should not ask WHEN onManageTokensDone THEN Done replaces stack`() = runTest { + coEvery { tangemSdkManager.checkCanUseBiometry() } returns true + coEvery { settingsRepository.shouldShowAskBiometry() } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onManageTokensDone() + advanceUntilIdle() + + Assertions.assertEquals(1, stack.size) + Assertions.assertTrue(stack.first() is OnboardingRoute.Done) + } + + @Test + fun `GIVEN Visa AND biometry available WHEN onManageTokensDone THEN BiometricScreenOpened analytics is sent`() = + runTest { + every { scanResponse.productType } returns ProductType.Visa + coEvery { tangemSdkManager.checkCanUseBiometry() } returns true + coEvery { settingsRepository.shouldShowAskBiometry() } returns true + + val model = createModel(this) + + model.onManageTokensDone() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(match { true }) + } + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + } + + @Test + fun `GIVEN Visa AND biometry not available WHEN onManageTokensDone THEN SuccessScreenOpened analytics is sent`() = + runTest { + every { scanResponse.productType } returns ProductType.Visa + coEvery { tangemSdkManager.checkCanUseBiometry() } returns false + + val model = createModel(this) + + model.onManageTokensDone() + advanceUntilIdle() + + verify { + analyticsEventHandler.send(match { true }) + } + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + } + + @Test + fun `GIVEN non-Visa WHEN onManageTokensDone THEN no Visa analytics sent`() = runTest { + every { scanResponse.productType } returns ProductType.Wallet2 + coEvery { tangemSdkManager.checkCanUseBiometry() } returns true + coEvery { settingsRepository.shouldShowAskBiometry() } returns true + + val model = createModel(this) + + model.onManageTokensDone() + advanceUntilIdle() + + verify(exactly = 0) { + analyticsEventHandler.send(match { true }) + } + } + + @Test + fun `WHEN onBack THEN CantLeaveBackupDialog is sent`() = runTest { + val model = createModel(this) + + model.onBack() + + verify { uiMessageSender.send(CantLeaveBackupDialog) } + } + + @Test + fun `WHEN onboardingTwinModelCallbacks onBack THEN router pop is called`() = runTest { + val model = createModel(this) + + model.onboardingTwinModelCallbacks.onBack() + + verify { router.pop(onComplete = any()) } + } + + @Test + fun `WHEN onboardingTwinModelCallbacks onDone THEN navigateToFinalScreenFlow runs`() = runTest { + coEvery { tangemSdkManager.checkCanUseBiometry() } returns false + + val model = createModel(this) + val stack = model.stackNavigation.trackStack() + + model.onboardingTwinModelCallbacks.onDone() + advanceUntilIdle() + + Assertions.assertEquals(1, stack.size) + val route = stack.first() + Assertions.assertTrue(route is OnboardingRoute.Done) + Assertions.assertEquals(OnboardingDoneComponent.Mode.WalletCreated, (route as OnboardingRoute.Done).mode) + } + + private fun StackNavigation.trackStack(): List { + val tracked = mutableListOf() + subscribe { event -> + val newStack = event.transformer(tracked.toList()) + tracked.clear() + tracked.addAll(newStack) + } + return tracked + } + + private fun createModel(testScope: TestScope): OnboardingEntryModel { + return OnboardingEntryModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + tangemSdkManager = tangemSdkManager, + settingsRepository = settingsRepository, + analyticsEventHandler = analyticsEventHandler, + uiMessageSender = uiMessageSender, + userWalletsListRepository = userWalletsListRepository, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + companion object { + + @JvmStatic + fun provideStartRouteByProductType(): List = listOf( + Arguments.of(ProductType.Wallet, OnboardingRoute.MultiWallet::class), + Arguments.of(ProductType.Wallet2, OnboardingRoute.MultiWallet::class), + Arguments.of(ProductType.Ring, OnboardingRoute.MultiWallet::class), + Arguments.of(ProductType.Note, OnboardingRoute.Note::class), + Arguments.of(ProductType.Twins, OnboardingRoute.Twins::class), + Arguments.of(ProductType.Visa, OnboardingRoute.Visa::class), + ) + + @JvmStatic + fun provideWallet2ModeMappings(): List { + val userWalletId = UserWalletId("011") + return listOf( + Arguments.of( + OnboardingEntryComponent.Mode.Onboarding, + OnboardingMultiWalletComponent.Mode.Onboarding, + ), + Arguments.of( + OnboardingEntryComponent.Mode.AddBackupWallet1, + OnboardingMultiWalletComponent.Mode.AddBackup, + ), + Arguments.of( + OnboardingEntryComponent.Mode.ContinueFinalize, + OnboardingMultiWalletComponent.Mode.ContinueFinalize, + ), + Arguments.of( + OnboardingEntryComponent.Mode.UpgradeHotWallet(userWalletId), + OnboardingMultiWalletComponent.Mode.UpgradeHotWallet(userWalletId), + ), + Arguments.of( + OnboardingEntryComponent.Mode.AddressSync(userWalletId, isWalletStarted = true), + OnboardingMultiWalletComponent.Mode.AddressSync(userWalletId, isWalletStarted = true), + ), + ) + } + + @JvmStatic + fun provideTwinsModeMappings(): List = listOf( + Arguments.of( + OnboardingEntryComponent.Mode.Onboarding, + OnboardingTwinComponent.Params.Mode.CreateWallet, + ), + Arguments.of( + OnboardingEntryComponent.Mode.WelcomeOnlyTwin, + OnboardingTwinComponent.Params.Mode.WelcomeOnly, + ), + Arguments.of( + OnboardingEntryComponent.Mode.RecreateWalletTwin, + OnboardingTwinComponent.Params.Mode.RecreateWallet, + ), + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt new file mode 100644 index 0000000000..92840e92da --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/child/finalize/model/MultiWalletFinalizeModelTest.kt @@ -0,0 +1,503 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.model + +import com.tangem.common.CompletionResult +import com.tangem.common.card.Card +import com.tangem.common.core.TangemSdkError +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.card.repository.CardRepository +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.models.scan.ScanResponse +import com.tangem.domain.onboarding.repository.OnboardingRepository +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.repository.WalletsRepository +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.domain.wallets.usecase.SyncWalletWithRemoteUseCase +import com.tangem.domain.wallets.usecase.UpdateWalletUseCase +import com.tangem.features.onboarding.v2.common.ui.CantLeaveBackupDialog +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.finalize.MultiWalletFinalizeComponent +import com.tangem.features.onboarding.v2.multiwallet.impl.child.finalize.ui.state.MultiWalletFinalizeUM +import com.tangem.features.onboarding.v2.multiwallet.impl.model.OnboardingMultiWalletState +import com.tangem.operations.backup.BackupService +import com.tangem.sdk.api.BackupServiceHolder +import com.tangem.sdk.api.TangemSdkManager +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +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 +import java.lang.ref.WeakReference + +@OptIn(ExperimentalCoroutinesApi::class) +internal class MultiWalletFinalizeModelTest { + + private val backupServiceHolder: BackupServiceHolder = mockk() + private val backupService: BackupService = mockk() + private val backupServiceWeakRef: WeakReference = WeakReference(backupService) + private val tangemSdkManager: TangemSdkManager = mockk(relaxUnitFun = true) + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk() + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk() + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk() + private val saveWalletUseCase: SaveWalletUseCase = mockk() + private val getUserWalletsUseCase: GetWalletsUseCase = mockk() + private val updateWalletUseCase: UpdateWalletUseCase = mockk() + private val syncWalletWithRemoteUseCase: SyncWalletWithRemoteUseCase = mockk() + private val cardRepository: CardRepository = mockk() + private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true) + private val walletsRepository: WalletsRepository = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true) + private val backupValidator: BackupValidator = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) + private val paramsContainer: ParamsContainer = mockk() + + private val scanResponse: ScanResponse = mockk() + + private val multiWalletStateFlow = MutableStateFlow( + OnboardingMultiWalletState( + currentStep = OnboardingMultiWalletState.Step.Finalize, + accessCode = null, + isThreeCards = true, + currentScanResponse = scanResponse, + startFromFinalize = null, + resultUserWallet = null, + ), + ) + + private val parentParams: OnboardingMultiWalletComponent.Params = mockk { + every { mode } returns OnboardingMultiWalletComponent.Mode.Onboarding + every { scanResponse } returns this@MultiWalletFinalizeModelTest.scanResponse + } + + private val params: MultiWalletChildParams = mockk { + every { multiWalletState } returns multiWalletStateFlow + every { parentParams } returns this@MultiWalletFinalizeModelTest.parentParams + } + + @BeforeEach + fun setUp() { + every { paramsContainer.require() } returns params + every { backupServiceHolder.backupService } returns backupServiceWeakRef + every { backupService.primaryCardId } returns "primary-id-aaaa" + every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID + every { backupService.backupCardIds } returns listOf("backup-1-bbbb", "backup-2-cccc") + every { backupService.backupCardsBatchIds } returns listOf(NON_RING_BATCH_ID, NON_RING_BATCH_ID) + every { backupService.currentState } returns BackupService.State.FinalizingPrimaryCard + coEvery { onboardingRepository.saveUnfinishedFinalizeOnboarding(any()) } just Runs + } + + @Test + fun `WHEN init AND startFromFinalize is null THEN no events emitted`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy(startFromFinalize = null) + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + + Assertions.assertEquals(emptyList(), events) + } + + @Test + fun `WHEN init AND startFromFinalize is ScanBackupFirstCard THEN OneBackupCardAdded emitted`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + + Assertions.assertEquals( + listOf(MultiWalletFinalizeComponent.Event.OneBackupCardAdded), + events, + ) + } + + @Test + fun `WHEN init AND startFromFinalize is ScanBackupSecondCard THEN both events emitted in order`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupSecondCard, + ) + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + + Assertions.assertEquals( + listOf( + MultiWalletFinalizeComponent.Event.OneBackupCardAdded, + MultiWalletFinalizeComponent.Event.TwoBackupCardsAdded, + ), + events, + ) + } + + @Test + fun `GIVEN backupService is null WHEN model is created THEN initial state is default`() = runTest { + every { backupServiceHolder.backupService } returns WeakReference(null) + + val model = createModel(this) + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.Primary, state.step) + Assertions.assertEquals(true, state.scanPrimary) + Assertions.assertEquals("", state.cardNumber) + Assertions.assertEquals(false, state.isRing) + } + + @Test + fun `GIVEN startFromFinalize null AND non-Ring primary WHEN model is created THEN state is Primary non-Ring`() = + runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy(startFromFinalize = null) + every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID + + val model = createModel(this) + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.Primary, state.step) + Assertions.assertEquals(true, state.scanPrimary) + Assertions.assertEquals(false, state.isRing) + Assertions.assertEquals("primary-id-aaaa".lastMaskedExpected(), state.cardNumber) + } + + @Test + fun `GIVEN startFromFinalize ScanPrimaryCard AND Ring primary WHEN model is created THEN state is Primary Ring`() = + runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanPrimaryCard, + ) + every { backupService.primaryCardBatchId } returns RING_BATCH_ID_AC17 + + val model = createModel(this) + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.Primary, state.step) + Assertions.assertEquals(true, state.scanPrimary) + Assertions.assertEquals(true, state.isRing) + } + + @Test + fun `GIVEN startFromFinalize ScanBackupFirstCard WHEN model is created THEN state is BackupDevice1`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice1, state.step) + Assertions.assertEquals(false, state.scanPrimary) + Assertions.assertEquals("backup-1-bbbb".lastMaskedExpected(), state.cardNumber) + } + + @Test + fun `GIVEN startFromFinalize ScanBackupSecondCard WHEN model is created THEN state is BackupDevice2`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupSecondCard, + ) + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice2, state.step) + Assertions.assertEquals(false, state.scanPrimary) + Assertions.assertEquals("backup-2-cccc".lastMaskedExpected(), state.cardNumber) + } + + @Test + fun `GIVEN scanPrimary true WHEN onBack THEN onBackFlow emits Unit`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy(startFromFinalize = null) + + val model = createModel(this) + val received = mutableListOf() + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onBackFlow.collect { received.add(it) } } + advanceUntilIdle() + + model.onBack() + advanceUntilIdle() + + Assertions.assertEquals(listOf(Unit), received) + verify(exactly = 0) { uiMessageSender.send(CantLeaveBackupDialog) } + } + + @Test + fun `GIVEN scanPrimary false WHEN onBack THEN CantLeaveBackupDialog is sent`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + model.onBack() + advanceUntilIdle() + + verify { uiMessageSender.send(CantLeaveBackupDialog) } + } + + @Test + fun `GIVEN primary batchId is null WHEN onScanClick THEN proceedBackup is not called`() = runTest { + every { backupService.primaryCardBatchId } returns null + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { backupService.proceedBackup(iconScanRes = any(), callback = any()) } + verify(exactly = 0) { tangemSdkManager.changeProductType(any()) } + } + + @Test + fun `GIVEN non-Ring primary AND success WHEN onScanClick THEN state moves to BackupDevice1`() = runTest { + every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { tangemSdkManager.changeProductType(false) } + verify { backupService.proceedBackup(iconScanRes = null, callback = any()) } + + callbackSlot.captured.invoke(CompletionResult.Success(mockk())) + advanceUntilIdle() + + verify { tangemSdkManager.clearProductType() } + coVerify { onboardingRepository.saveUnfinishedFinalizeOnboarding(scanResponse = scanResponse) } + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice1, state.step) + Assertions.assertEquals(false, state.scanPrimary) + Assertions.assertEquals("backup-1-bbbb".lastMaskedExpected(), state.cardNumber) + Assertions.assertEquals(false, state.isRing) + Assertions.assertEquals( + listOf(MultiWalletFinalizeComponent.Event.OneBackupCardAdded), + events, + ) + } + + @Test + fun `GIVEN Ring primary WHEN onScanClick THEN changeProductType is true and ring icon is used`() = runTest { + every { backupService.primaryCardBatchId } returns RING_BATCH_ID_AC17 + every { + backupService.proceedBackup(iconScanRes = any(), callback = any()) + } just Runs + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify { tangemSdkManager.changeProductType(true) } + verify { + backupService.proceedBackup( + iconScanRes = com.tangem.features.onboarding.v2.impl.R.drawable.img_hand_scan_ring, + callback = any(), + ) + } + } + + @Test + fun `GIVEN primary AND failure WHEN onScanClick THEN state is unchanged AND no event emitted`() = runTest { + every { backupService.primaryCardBatchId } returns NON_RING_BATCH_ID + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect { events.add(it) } } + advanceUntilIdle() + val stateBefore = model.uiState.value + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + callbackSlot.captured.invoke(CompletionResult.Failure(TangemSdkError.UserCancelled())) + advanceUntilIdle() + + verify { tangemSdkManager.clearProductType() } + coVerify(exactly = 0) { onboardingRepository.saveUnfinishedFinalizeOnboarding(any()) } + Assertions.assertEquals(stateBefore.step, model.uiState.value.step) + Assertions.assertEquals(stateBefore.scanPrimary, model.uiState.value.scanPrimary) + Assertions.assertEquals(emptyList(), events) + } + + @Test + fun `GIVEN BackupDevice1 AND batchId null WHEN onScanClick THEN proceedBackup is not called`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + every { backupService.backupCardsBatchIds } returns emptyList() + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + + verify(exactly = 0) { backupService.proceedBackup(iconScanRes = any(), callback = any()) } + } + + @Test + fun `GIVEN BackupDevice1 AND failure WalletAlreadyCreated WHEN onScanClick THEN dialog is set`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + callbackSlot.captured.invoke(CompletionResult.Failure(TangemSdkError.WalletAlreadyCreated())) + advanceUntilIdle() + + Assertions.assertNotNull(model.uiState.value.dialog) + verify { tangemSdkManager.clearProductType() } + } + + @Test + fun `GIVEN BackupDevice1 AND failure other error WHEN onScanClick THEN no dialog is set`() = runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { model.onEvent.collect {} } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + callbackSlot.captured.invoke(CompletionResult.Failure(TangemSdkError.UserCancelled())) + advanceUntilIdle() + + Assertions.assertNull(model.uiState.value.dialog) + verify { tangemSdkManager.clearProductType() } + } + + @Test + fun `GIVEN BackupDevice1 AND success AND not Finished WHEN onScanClick THEN state moves to BackupDevice2`() = + runTest { + multiWalletStateFlow.value = multiWalletStateFlow.value.copy( + startFromFinalize = OnboardingMultiWalletState.FinalizeStage.ScanBackupFirstCard, + ) + every { backupService.currentState } returns BackupService.State.FinalizingBackupCard(index = 1) + + mockkConstructor(BackupValidator::class) + every { anyConstructed().isValidBackupStatus(any()) } returns true + + val card: Card = mockk(relaxed = true) + val callbackSlot = slot<(CompletionResult) -> Unit>() + every { + backupService.proceedBackup(iconScanRes = null, callback = capture(callbackSlot)) + } just Runs + + val events = mutableListOf() + val model = createModel(this) + backgroundScope.launch(context = Dispatchers.Unconfined, start = CoroutineStart.UNDISPATCHED) { + model.onEvent.collect { events.add(it) } + } + advanceUntilIdle() + + model.uiState.value.onScanClick.invoke() + advanceUntilIdle() + callbackSlot.captured.invoke(CompletionResult.Success(card)) + advanceUntilIdle() + + val state = model.uiState.value + Assertions.assertEquals(MultiWalletFinalizeUM.Step.BackupDevice2, state.step) + Assertions.assertEquals("backup-2-cccc".lastMaskedExpected(), state.cardNumber) + Assertions.assertTrue(events.contains(MultiWalletFinalizeComponent.Event.TwoBackupCardsAdded)) + + unmockkConstructor(BackupValidator::class) + } + + private fun String.lastMaskedExpected(): String { + val space = ' ' + val last4 = takeLast(4) + return "$space*$space*$space*$space$last4" + } + + private fun createModel(testScope: TestScope): MultiWalletFinalizeModel { + return MultiWalletFinalizeModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + backupServiceHolder = backupServiceHolder, + tangemSdkManager = tangemSdkManager, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, + saveWalletUseCase = saveWalletUseCase, + getUserWalletsUseCase = getUserWalletsUseCase, + updateWalletUseCase = updateWalletUseCase, + syncWalletWithRemoteUseCase = syncWalletWithRemoteUseCase, + cardRepository = cardRepository, + onboardingRepository = onboardingRepository, + walletsRepository = walletsRepository, + uiMessageSender = uiMessageSender, + backupValidator = backupValidator, + analyticsEventHandler = analyticsEventHandler, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private companion object { + private const val NON_RING_BATCH_ID = "AC02" + private const val RING_BATCH_ID_AC17 = "AC17" + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt new file mode 100644 index 0000000000..3b10fcbbbe --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/OnboardingMultiWalletModelTest.kt @@ -0,0 +1,454 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.model + +import com.tangem.common.card.Card +import com.tangem.common.card.FirmwareVersion as SdkFirmwareVersion +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.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.core.ui.components.artwork.ArtworkUM +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.ArtworkModel +import com.tangem.domain.models.scan.CardDTO +import com.tangem.domain.models.scan.ProductType +import com.tangem.domain.models.scan.ScanResponse +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.features.onboarding.v2.impl.R +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.title.OnboardingTitle +import com.tangem.operations.attestation.ArtworkSize +import com.tangem.operations.backup.BackupService +import com.tangem.sdk.api.BackupServiceHolder +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 +import java.lang.ref.WeakReference +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +internal class OnboardingMultiWalletModelTest { + + private val analyticsHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) + private val router: Router = mockk(relaxUnitFun = true) + private val backupServiceHolder: BackupServiceHolder = mockk() + private val backupServiceWeakRef: WeakReference = WeakReference(null) + private val onboardingRepository: OnboardingRepository = mockk(relaxUnitFun = true) + private val getCardImageUseCase: GetCardImageUseCase = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxUnitFun = true) + private val artworkUMConverter: ArtworkUMConverter = mockk() + private val paramsContainer: ParamsContainer = mockk() + private val titleProvider: TitleProvider = mockk(relaxUnitFun = true) + + private val card1Id = "card-id-1" + private val card1PublicKey = byteArrayOf(1, 2, 3) + private val card1ManufacturerName = "Tangem" + private val card1Manufacturer = CardDTO.Manufacturer( + name = card1ManufacturerName, + manufactureDate = Date(0), + signature = null, + ) + private val card1FirmwareVersionDto = CardDTO.FirmwareVersion( + major = 6, + minor = 33, + patch = 0, + type = SdkFirmwareVersion.FirmwareType.Release, + ) + private val card1SdkFirmwareVersion = SdkFirmwareVersion(major = 6, minor = 33) + private val cardDto: CardDTO = mockk() + private val scanResponse: ScanResponse = mockk() + private val artwork1Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-1") + private val artwork1Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-1") + + private val params: OnboardingMultiWalletComponent.Params = mockk { + every { titleProvider } returns this@OnboardingMultiWalletModelTest.titleProvider + every { scanResponse } returns this@OnboardingMultiWalletModelTest.scanResponse + } + + @BeforeEach + fun setUp() { + every { paramsContainer.require() } returns params + every { params.mode } returns OnboardingMultiWalletComponent.Mode.Onboarding + + every { cardDto.cardId } returns card1Id + every { cardDto.cardPublicKey } returns card1PublicKey + every { cardDto.manufacturer } returns card1Manufacturer + every { cardDto.firmwareVersion } returns card1FirmwareVersionDto + every { cardDto.wallets } returns emptyList() + every { cardDto.backupStatus } returns null + + every { scanResponse.card } returns cardDto + every { scanResponse.productType } returns ProductType.Wallet + every { scanResponse.primaryCard } returns null + + every { backupServiceHolder.backupService } returns backupServiceWeakRef + + coEvery { + getCardImageUseCase.invoke( + cardId = card1Id, + cardPublicKey = card1PublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card1ManufacturerName, + firmwareVersion = card1SdkFirmwareVersion, + ) + } returns artwork1Model + every { artworkUMConverter.convert(artwork1Model) } returns artwork1Um + } + + @Test + fun `WHEN model is created THEN OnboardingEvent Started is sent`() = runTest { + createModel(this) + advanceUntilIdle() + + verify { analyticsHandler.send(match { true }) } + } + + @Test + fun `GIVEN UpgradeHotWallet mode WHEN model is created THEN title is common_tangem`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.UpgradeHotWallet( + userWalletId = UserWalletId("011"), + ) + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.common_tangem)), + ) + } + } + + @Test + fun `GIVEN ContinueFinalize mode WHEN model is created THEN title is finalize_backup`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.ContinueFinalize + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_button_finalize_backup)), + ) + } + } + + @Test + fun `GIVEN AddressSync mode WHEN model is created THEN title is biometrics`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.AddressSync( + userWalletId = UserWalletId("011"), + isWalletStarted = false, + ) + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_navbar_title_biometrics)), + ) + } + } + + @Test + fun `GIVEN wallets present AND NoBackup AND no primary card WHEN created THEN title is creating_backup`() = runTest { + every { cardDto.wallets } returns listOf(mockk()) + every { cardDto.backupStatus } returns CardDTO.BackupStatus.NoBackup + every { scanResponse.primaryCard } returns null + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_navbar_title_creating_backup)), + ) + } + } + + @Test + fun `GIVEN wallets present AND NoBackup AND Wallet productType WHEN created THEN title is getting_started`() = + runTest { + every { cardDto.wallets } returns listOf(mockk()) + every { cardDto.backupStatus } returns CardDTO.BackupStatus.NoBackup + every { scanResponse.primaryCard } returns mockk() + every { scanResponse.productType } returns ProductType.Wallet + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_getting_started)), + ) + } + } + + @Test + fun `GIVEN wallets present AND NoBackup AND non-Wallet productType WHEN created THEN title is creating_backup`() = + runTest { + every { cardDto.wallets } returns listOf(mockk()) + every { cardDto.backupStatus } returns CardDTO.BackupStatus.NoBackup + every { scanResponse.primaryCard } returns mockk() + every { scanResponse.productType } returns ProductType.Wallet2 + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_navbar_title_creating_backup)), + ) + } + } + + @Test + fun `GIVEN wallets present AND active backup WHEN created THEN title is finalize_backup`() = runTest { + every { cardDto.wallets } returns listOf(mockk()) + every { cardDto.backupStatus } returns CardDTO.BackupStatus.Active(cardCount = 2) + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_button_finalize_backup)), + ) + } + } + + @Test + fun `GIVEN no wallets WHEN model is created THEN title is create_wallet_header`() = runTest { + every { cardDto.wallets } returns emptyList() + + createModel(this) + advanceUntilIdle() + + verify { + titleProvider.changeTitle( + title = OnboardingTitle(text = resourceReference(R.string.onboarding_create_wallet_header)), + ) + } + } + + @Test + fun `WHEN model is created THEN loadCardArtwork updates artwork1 in uiState`() = runTest { + val model = createModel(this) + advanceUntilIdle() + + coVerify { + getCardImageUseCase.invoke( + cardId = card1Id, + cardPublicKey = card1PublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card1ManufacturerName, + firmwareVersion = card1SdkFirmwareVersion, + ) + } + verify { artworkUMConverter.convert(artwork1Model) } + Assertions.assertEquals(artwork1Um, model.uiState.value.artwork1) + } + + @Test + fun `GIVEN backups emit card2 WHEN subscribeToBackups THEN artwork2 is loaded and updated`() = runTest { + val card2Info = card2BackupInfo() + val artwork2Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-2") + val artwork2Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-2") + coEvery { + getCardImageUseCase.invoke( + cardId = card2Info.cardId, + cardPublicKey = card2Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card2Info.manufacturer.name, + firmwareVersion = card2Info.firmwareVersion, + ) + } returns artwork2Model + every { artworkUMConverter.convert(artwork2Model) } returns artwork2Um + + val model = createModel(this) + advanceUntilIdle() + + model.backups.value = MultiWalletChildParams.Backup(card2 = card2Info) + advanceUntilIdle() + + coVerify { + getCardImageUseCase.invoke( + cardId = card2Info.cardId, + cardPublicKey = card2Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card2Info.manufacturer.name, + firmwareVersion = card2Info.firmwareVersion, + ) + } + Assertions.assertEquals(artwork2Um, model.uiState.value.artwork2) + } + + @Test + fun `GIVEN backups emit card3 after card2 WHEN subscribeToBackups THEN artwork3 is loaded and updated`() = runTest { + val card2Info = card2BackupInfo() + val card3Info = card3BackupInfo() + val artwork2Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-2") + val artwork2Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-2") + val artwork3Model = ArtworkModel(verifiedArtwork = null, defaultUrl = "default-url-3") + val artwork3Um = ArtworkUM(verifiedArtwork = null, defaultUrl = "default-url-3") + coEvery { + getCardImageUseCase.invoke( + cardId = card2Info.cardId, + cardPublicKey = card2Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card2Info.manufacturer.name, + firmwareVersion = card2Info.firmwareVersion, + ) + } returns artwork2Model + every { artworkUMConverter.convert(artwork2Model) } returns artwork2Um + coEvery { + getCardImageUseCase.invoke( + cardId = card3Info.cardId, + cardPublicKey = card3Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card3Info.manufacturer.name, + firmwareVersion = card3Info.firmwareVersion, + ) + } returns artwork3Model + every { artworkUMConverter.convert(artwork3Model) } returns artwork3Um + + val model = createModel(this) + advanceUntilIdle() + + model.backups.value = MultiWalletChildParams.Backup(card2 = card2Info) + advanceUntilIdle() + model.backups.value = MultiWalletChildParams.Backup(card2 = card2Info, card3 = card3Info) + advanceUntilIdle() + + coVerify { + getCardImageUseCase.invoke( + cardId = card3Info.cardId, + cardPublicKey = card3Info.cardPublicKey, + size = ArtworkSize.LARGE, + manufacturerName = card3Info.manufacturer.name, + firmwareVersion = card3Info.firmwareVersion, + ) + } + Assertions.assertEquals(artwork3Um, model.uiState.value.artwork3) + } + + @Test + fun `GIVEN non-AddressSync mode WHEN onBack confirmed THEN router pop is called`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.Onboarding + coEvery { onboardingRepository.clearUnfinishedFinalizeOnboarding() } just Runs + val dialogSlot = slot() + every { uiMessageSender.send(capture(dialogSlot)) } just Runs + + val model = createModel(this) + advanceUntilIdle() + + model.onBack() + dialogSlot.captured.firstAction.onClick.invoke() + advanceUntilIdle() + + coVerify { onboardingRepository.clearUnfinishedFinalizeOnboarding() } + verify { router.pop(onComplete = any()) } + verify(exactly = 0) { router.popTo(route = any(), onComplete = any()) } + verify(exactly = 0) { router.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN AddressSync mode AND wallet started WHEN onBack confirmed THEN popTo Wallet is called`() = runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.AddressSync( + userWalletId = UserWalletId("011"), + isWalletStarted = true, + ) + coEvery { onboardingRepository.clearUnfinishedFinalizeOnboarding() } just Runs + val dialogSlot = slot() + every { uiMessageSender.send(capture(dialogSlot)) } just Runs + + val model = createModel(this) + advanceUntilIdle() + + model.onBack() + dialogSlot.captured.firstAction.onClick.invoke() + advanceUntilIdle() + + coVerify { onboardingRepository.clearUnfinishedFinalizeOnboarding() } + verify { router.popTo(route = AppRoute.Wallet, onComplete = any()) } + verify(exactly = 0) { router.pop(onComplete = any()) } + verify(exactly = 0) { router.replaceAll(routes = anyVararg(), onComplete = any()) } + } + + @Test + fun `GIVEN AddressSync mode AND wallet not started WHEN onBack confirmed THEN replaceAll Wallet is called`() = + runTest { + every { params.mode } returns OnboardingMultiWalletComponent.Mode.AddressSync( + userWalletId = UserWalletId("011"), + isWalletStarted = false, + ) + coEvery { onboardingRepository.clearUnfinishedFinalizeOnboarding() } just Runs + val dialogSlot = slot() + every { uiMessageSender.send(capture(dialogSlot)) } just Runs + + val model = createModel(this) + advanceUntilIdle() + + model.onBack() + dialogSlot.captured.firstAction.onClick.invoke() + advanceUntilIdle() + + coVerify { onboardingRepository.clearUnfinishedFinalizeOnboarding() } + verify { router.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) } + verify(exactly = 0) { router.pop(onComplete = any()) } + verify(exactly = 0) { router.popTo(route = any(), onComplete = any()) } + } + + private fun card2BackupInfo() = MultiWalletChildParams.Backup.BackupCardInfo( + cardId = "card-id-2", + cardPublicKey = byteArrayOf(4, 5, 6), + manufacturer = Card.Manufacturer(name = "Tangem2", manufactureDate = Date(0), signature = null), + firmwareVersion = SdkFirmwareVersion(major = 6, minor = 34), + ) + + private fun card3BackupInfo() = MultiWalletChildParams.Backup.BackupCardInfo( + cardId = "card-id-3", + cardPublicKey = byteArrayOf(7, 8, 9), + manufacturer = Card.Manufacturer(name = "Tangem3", manufactureDate = Date(0), signature = null), + firmwareVersion = SdkFirmwareVersion(major = 6, minor = 35), + ) + + private fun createModel(testScope: TestScope): OnboardingMultiWalletModel { + return OnboardingMultiWalletModel( + paramsContainer = paramsContainer, + analyticsHandler = analyticsHandler, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + backupServiceHolder = backupServiceHolder, + onboardingRepository = onboardingRepository, + getCardImageUseCase = getCardImageUseCase, + uiMessageSender = uiMessageSender, + artworkUMConverter = artworkUMConverter, + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } +} \ No newline at end of file diff --git a/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt new file mode 100644 index 0000000000..8c2e762454 --- /dev/null +++ b/features/onboarding-v2/impl/src/test/kotlin/com/tangem/features/onboarding/v2/multiwallet/impl/model/UtilsTest.kt @@ -0,0 +1,66 @@ +package com.tangem.features.onboarding.v2.multiwallet.impl.model + +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.onboarding.v2.impl.R +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +internal class UtilsTest { + + @ParameterizedTest + @MethodSource("provideScreenTitleByStep") + fun `GIVEN step WHEN screenTitleByStep THEN expected text reference is returned`( + step: OnboardingMultiWalletState.Step, + expected: TextReference, + ) { + val actual = screenTitleByStep(step) + + Assertions.assertEquals(expected, actual) + } + + companion object { + + @JvmStatic + fun provideScreenTitleByStep(): List = listOf( + Arguments.of( + OnboardingMultiWalletState.Step.UpgradeWallet, + resourceReference(R.string.common_tangem), + ), + Arguments.of( + OnboardingMultiWalletState.Step.CreateWallet, + resourceReference(R.string.onboarding_create_wallet_header), + ), + Arguments.of( + OnboardingMultiWalletState.Step.SeedPhrase, + resourceReference(R.string.onboarding_create_wallet_header), + ), + Arguments.of( + OnboardingMultiWalletState.Step.ChooseBackupOption, + resourceReference(R.string.onboarding_getting_started), + ), + Arguments.of( + OnboardingMultiWalletState.Step.ScanPrimary, + resourceReference(R.string.onboarding_navbar_title_creating_backup), + ), + Arguments.of( + OnboardingMultiWalletState.Step.AddBackupDevice, + resourceReference(R.string.onboarding_navbar_title_creating_backup), + ), + Arguments.of( + OnboardingMultiWalletState.Step.AddressSync, + resourceReference(R.string.onboarding_navbar_title_biometrics), + ), + Arguments.of( + OnboardingMultiWalletState.Step.Finalize, + resourceReference(R.string.onboarding_button_finalize_backup), + ), + Arguments.of( + OnboardingMultiWalletState.Step.Done, + resourceReference(R.string.common_done), + ), + ) + } +} \ No newline at end of file From 229dc89bcbdc4aa19e2e4279ee563d46aba6927b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Apr 2026 10:28:27 +0200 Subject: [PATCH 137/206] Updated on 2026-08-14 --- app/build.gradle.kts | 11 +- .../tangem/tap/di/data/VisaStorageModule.kt | 6 - .../MockAwareDeviceSecurityInfoProvider.kt | 30 ++++ .../tap/data/MockAwareTangemPayStorage.kt | 129 ++++++++++++++++++ .../di/core/security/SecurityMockedModule.kt | 25 ++++ .../di/data/TangemPayStorageMockedModule.kt | 18 +++ .../security/SecurityProductionModule.kt} | 2 +- .../data/TangemPayStorageProductionModule.kt | 18 +++ .../message/MessageBottomSheet.kt | 10 +- .../ui/test/HotWalletAccessCodeTestTags.kt | 5 + .../tangem/core/ui/test/TangemPayTestTags.kt | 39 ++++++ .../ui/test/WarningBottomSheetTestTags.kt | 2 + data/visa/build.gradle.kts | 10 ++ .../tangem/data/pay/di/TangemPayDataModule.kt | 8 -- .../data/pay/di/TangemPayDataMockedModule.kt | 24 ++++ .../MockAwareOnboardingRepository.kt | 110 +++++++++++++++ ...MockAwareTangemPayCardDetailsRepository.kt | 100 ++++++++++++++ .../pay/di/TangemPayDataProductionModule.kt | 24 ++++ .../hotwallet/accesscode/ui/AccessCode.kt | 3 + .../tangempay/entity/TangemPayCardPageUM.kt | 1 + .../tangempay/model/TangemPayCardPageModel.kt | 2 + .../tangempay/ui/TangemPayCardDetailsBlock.kt | 36 ++++- .../tangempay/ui/TangemPayCardPageScreen.kt | 4 +- .../ui/TangemPayChangePinCodeSuccessScreen.kt | 15 +- .../tangempay/ui/TangemPayChangePinScreen.kt | 13 +- .../tangempay/ui/TangemPayDetailsScreen.kt | 5 +- .../tangempay/ui/TangemPayMainBlockContent.kt | 5 +- .../ui/TangemPayMainBlockContentLegacy.kt | 4 +- 28 files changed, 623 insertions(+), 36 deletions(-) create mode 100644 app/src/mocked/java/com/tangem/tap/core/security/MockAwareDeviceSecurityInfoProvider.kt create mode 100644 app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt create mode 100644 app/src/mocked/java/com/tangem/tap/di/core/security/SecurityMockedModule.kt create mode 100644 app/src/mocked/java/com/tangem/tap/di/data/TangemPayStorageMockedModule.kt rename app/src/{main/java/com/tangem/tap/di/core/security/SecurityModule.kt => prodDi/java/com/tangem/tap/di/core/security/SecurityProductionModule.kt} (92%) create mode 100644 app/src/prodDi/java/com/tangem/tap/di/data/TangemPayStorageProductionModule.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/HotWalletAccessCodeTestTags.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt create mode 100644 data/visa/src/mocked/kotlin/com/tangem/data/pay/di/TangemPayDataMockedModule.kt create mode 100644 data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt create mode 100644 data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt create mode 100644 data/visa/src/prodDi/kotlin/com/tangem/data/pay/di/TangemPayDataProductionModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8bc51a5ecd..25cd960782 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,7 +61,7 @@ android { } flavorDimensions += "services" - + productFlavors { create("google") { dimension = "services" @@ -73,6 +73,15 @@ android { } } + // `src/prodDi/` holds production DI bindings for interfaces with a `mocked` counterpart. + // Wired into every build type EXCEPT `mocked`, which supplies its own bindings from `src/mocked/`. + buildTypes.configureEach { + if (name != "mocked") { + sourceSets.named(name) { + java.srcDir("src/prodDi/java") + } + } + } } configurations.all { diff --git a/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt b/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt index 1927963c23..c78909a676 100644 --- a/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/VisaStorageModule.kt @@ -1,9 +1,7 @@ package com.tangem.tap.di.data -import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.datasource.local.visa.VisaAuthTokenStorage import com.tangem.datasource.local.visa.VisaOTPStorage -import com.tangem.tap.data.DefaultTangemPayStorage import com.tangem.tap.data.DefaultVisaAuthTokenStorage import com.tangem.tap.data.DefaultVisaOTPStorage import dagger.Binds @@ -23,8 +21,4 @@ internal interface VisaStorageModule { @Binds @Singleton fun bindVisaOTPStorage(impl: DefaultVisaOTPStorage): VisaOTPStorage - - @Binds - @Singleton - fun bindTangemPayStorage(impl: DefaultTangemPayStorage): TangemPayStorage } \ No newline at end of file diff --git a/app/src/mocked/java/com/tangem/tap/core/security/MockAwareDeviceSecurityInfoProvider.kt b/app/src/mocked/java/com/tangem/tap/core/security/MockAwareDeviceSecurityInfoProvider.kt new file mode 100644 index 0000000000..9ccfeedce5 --- /dev/null +++ b/app/src/mocked/java/com/tangem/tap/core/security/MockAwareDeviceSecurityInfoProvider.kt @@ -0,0 +1,30 @@ +package com.tangem.tap.core.security + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.security.DeviceSecurityInfoProvider + +/** In MOCK env reports a clean device; otherwise delegates (DexProtector RTC flags emulators). */ +internal class MockAwareDeviceSecurityInfoProvider( + private val real: DeviceSecurityInfoProvider, + private val apiConfigsManager: ApiConfigsManager, +) : DeviceSecurityInfoProvider { + + private val isMockMode: Boolean + get() = apiConfigsManager + .getEnvironmentConfig(ApiConfig.ID.TangemPay) + .environment == ApiEnvironment.MOCK + + override val isRooted: Boolean + get() = if (isMockMode) false else real.isRooted + + override val isBootloaderUnlocked: Boolean + get() = if (isMockMode) false else real.isBootloaderUnlocked + + override val isXposed: Boolean + get() = if (isMockMode) false else real.isXposed + + override val isVulnerableToMediaTekExploit: Boolean + get() = if (isMockMode) false else real.isVulnerableToMediaTekExploit +} \ No newline at end of file diff --git a/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt new file mode 100644 index 0000000000..eabe4ccda7 --- /dev/null +++ b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt @@ -0,0 +1,129 @@ +package com.tangem.tap.data + +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayWithdrawState +import com.tangem.domain.visa.model.TangemPayAuthTokens +import javax.inject.Inject +import javax.inject.Singleton + +private const val MOCK_CUSTOMER_WALLET_ADDRESS = "0x0000000000000000000000000000000000000002" +private const val MOCK_ACCESS_TOKEN = "mock-access-token" +private const val MOCK_REFRESH_TOKEN = "mock-refresh-token" +private const val MOCK_IDEMPOTENCY_KEY = "mock-idempotency-key" +private const val MOCK_TOKEN_EXPIRES_AT = 9_999_999_999L + +/** In MOCK env returns synthetic auth tokens + customer wallet address; otherwise delegates. */ +@Singleton +internal class MockAwareTangemPayStorage @Inject constructor( + private val real: DefaultTangemPayStorage, + private val apiConfigsManager: ApiConfigsManager, +) : TangemPayStorage { + + private val isMockMode: Boolean + get() = apiConfigsManager + .getEnvironmentConfig(ApiConfig.ID.TangemPay) + .environment == ApiEnvironment.MOCK + + override suspend fun storeCustomerWalletAddress(userWalletId: UserWalletId, customerWalletAddress: String) { + if (isMockMode) return + real.storeCustomerWalletAddress(userWalletId, customerWalletAddress) + } + + override suspend fun getCustomerWalletAddress(userWalletId: UserWalletId): String? { + if (isMockMode) return MOCK_CUSTOMER_WALLET_ADDRESS + return real.getCustomerWalletAddress(userWalletId) + } + + override suspend fun clearCustomerWalletAddress(userWalletId: UserWalletId) { + if (isMockMode) return + real.clearCustomerWalletAddress(userWalletId) + } + + override suspend fun storeAuthTokens(customerWalletAddress: String, tokens: TangemPayAuthTokens) { + if (isMockMode) return + real.storeAuthTokens(customerWalletAddress, tokens) + } + + override suspend fun getAuthTokens(customerWalletAddress: String): TangemPayAuthTokens? { + if (isMockMode) { + return TangemPayAuthTokens( + accessToken = MOCK_ACCESS_TOKEN, + expiresAt = MOCK_TOKEN_EXPIRES_AT, + refreshToken = MOCK_REFRESH_TOKEN, + refreshExpiresAt = MOCK_TOKEN_EXPIRES_AT, + idempotencyKey = MOCK_IDEMPOTENCY_KEY, + ) + } + return real.getAuthTokens(customerWalletAddress) + } + + override suspend fun clearAuthTokens(customerWalletAddress: String) { + if (isMockMode) return + real.clearAuthTokens(customerWalletAddress) + } + + override suspend fun storeOrderId(customerWalletAddress: String, orderId: String) = + real.storeOrderId(customerWalletAddress, orderId) + + override suspend fun getOrderId(customerWalletAddress: String): String? = + real.getOrderId(customerWalletAddress) + + override suspend fun getAddToWalletDone(customerWalletAddress: String): Boolean = + real.getAddToWalletDone(customerWalletAddress) + + override suspend fun storeAddToWalletDone(customerWalletAddress: String, isDone: Boolean) = + real.storeAddToWalletDone(customerWalletAddress, isDone) + + override suspend fun clearOrderId(customerWalletAddress: String) = + real.clearOrderId(customerWalletAddress) + + override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) = + real.storeCheckCustomerWalletResult(userWalletId, isPaeraCustomer) + + override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? { + if (isMockMode) return true + return real.checkCustomerWalletResult(userWalletId) + } + + override suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String) = + real.storeActiveWithdrawOrderId(userWalletId, orderId) + + override suspend fun storeWithdrawOrder(userWalletId: UserWalletId, data: TangemPayWithdrawState) = + real.storeWithdrawOrder(userWalletId, data) + + override suspend fun getActiveWithdrawOrderId(userWalletId: UserWalletId): String? = + real.getActiveWithdrawOrderId(userWalletId) + + override suspend fun getWithdrawOrders(userWalletId: UserWalletId): List? = + real.getWithdrawOrders(userWalletId) + + override suspend fun deleteActiveWithdrawOrder(userWalletId: UserWalletId) = + real.deleteActiveWithdrawOrder(userWalletId) + + override suspend fun deleteWithdrawOrder(userWalletId: UserWalletId, orderId: String) = + real.deleteWithdrawOrder(userWalletId, orderId) + + override suspend fun storeHideOnboardingBanner(userWalletId: UserWalletId, hide: Boolean) = + real.storeHideOnboardingBanner(userWalletId, hide) + + override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean = + real.getHideMainOnboardingBanner(userWalletId) + + override suspend fun storeTangemPayEligibility(eligibility: Set) = + real.storeTangemPayEligibility(eligibility) + + override suspend fun getTangemPayEligibility(): Set = real.getTangemPayEligibility() + + override suspend fun storeIsTangemPayDeactivated(userWalletId: UserWalletId) = + real.storeIsTangemPayDeactivated(userWalletId) + + override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean = + real.isTangemPayDeactivated(userWalletId) + + override suspend fun clearAll(userWalletId: UserWalletId, customerWalletAddress: String) = + real.clearAll(userWalletId, customerWalletAddress) +} \ No newline at end of file diff --git a/app/src/mocked/java/com/tangem/tap/di/core/security/SecurityMockedModule.kt b/app/src/mocked/java/com/tangem/tap/di/core/security/SecurityMockedModule.kt new file mode 100644 index 0000000000..9216155ec9 --- /dev/null +++ b/app/src/mocked/java/com/tangem/tap/di/core/security/SecurityMockedModule.kt @@ -0,0 +1,25 @@ +package com.tangem.tap.di.core.security + +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.security.DeviceSecurityInfoProvider +import com.tangem.tap.core.security.DefaultDeviceSecurityInfoProvider +import com.tangem.tap.core.security.MockAwareDeviceSecurityInfoProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object SecurityMockedModule { + + @Provides + @Singleton + fun provideDeviceSecurityInfoProvider( + apiConfigsManager: ApiConfigsManager, + ): DeviceSecurityInfoProvider { + val real = DefaultDeviceSecurityInfoProvider() + return MockAwareDeviceSecurityInfoProvider(real = real, apiConfigsManager = apiConfigsManager) + } +} \ No newline at end of file diff --git a/app/src/mocked/java/com/tangem/tap/di/data/TangemPayStorageMockedModule.kt b/app/src/mocked/java/com/tangem/tap/di/data/TangemPayStorageMockedModule.kt new file mode 100644 index 0000000000..8ccf4a8ac4 --- /dev/null +++ b/app/src/mocked/java/com/tangem/tap/di/data/TangemPayStorageMockedModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di.data + +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.tap.data.MockAwareTangemPayStorage +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayStorageMockedModule { + + @Binds + @Singleton + fun bindTangemPayStorage(impl: MockAwareTangemPayStorage): TangemPayStorage +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/core/security/SecurityModule.kt b/app/src/prodDi/java/com/tangem/tap/di/core/security/SecurityProductionModule.kt similarity index 92% rename from app/src/main/java/com/tangem/tap/di/core/security/SecurityModule.kt rename to app/src/prodDi/java/com/tangem/tap/di/core/security/SecurityProductionModule.kt index b4e64ccb37..0f65cf0c19 100644 --- a/app/src/main/java/com/tangem/tap/di/core/security/SecurityModule.kt +++ b/app/src/prodDi/java/com/tangem/tap/di/core/security/SecurityProductionModule.kt @@ -10,7 +10,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object SecurityModule { +internal object SecurityProductionModule { @Provides @Singleton diff --git a/app/src/prodDi/java/com/tangem/tap/di/data/TangemPayStorageProductionModule.kt b/app/src/prodDi/java/com/tangem/tap/di/data/TangemPayStorageProductionModule.kt new file mode 100644 index 0000000000..dffc33767d --- /dev/null +++ b/app/src/prodDi/java/com/tangem/tap/di/data/TangemPayStorageProductionModule.kt @@ -0,0 +1,18 @@ +package com.tangem.tap.di.data + +import com.tangem.datasource.local.visa.TangemPayStorage +import com.tangem.tap.data.DefaultTangemPayStorage +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayStorageProductionModule { + + @Binds + @Singleton + fun bindTangemPayStorage(impl: DefaultTangemPayStorage): TangemPayStorage +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt index c32033908e..9d5d8700a5 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheet.kt @@ -216,7 +216,15 @@ private fun ButtonsContainer( } ?: TangemButtonIconPosition.None TangemButton( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag( + if (button.isPrimary) { + WarningBottomSheetTestTags.BUTTON_PRIMARY + } else { + WarningBottomSheetTestTags.BUTTON_SECONDARY + }, + ), text = button.text?.resolveReference().orEmpty(), icon = icon, onClick = { button.onClick?.invoke(closeScope) }, diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/HotWalletAccessCodeTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/HotWalletAccessCodeTestTags.kt new file mode 100644 index 0000000000..a71b596eae --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/HotWalletAccessCodeTestTags.kt @@ -0,0 +1,5 @@ +package com.tangem.core.ui.test + +object HotWalletAccessCodeTestTags { + const val ACCESS_CODE_INPUT = "HOT_WALLET_ACCESS_CODE_INPUT" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt new file mode 100644 index 0000000000..ffd6ed9254 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt @@ -0,0 +1,39 @@ +package com.tangem.core.ui.test + +object TangemPayTestTags { + // Main wallet screen tile (entry point into Tangem Pay) + const val MAIN_SCREEN_TILE = "TANGEM_PAY_MAIN_SCREEN_TILE" + + // Payment account details screen + const val PAYMENT_ACCOUNT_BALANCE = "TANGEM_PAY_PAYMENT_ACCOUNT_BALANCE" + const val PAYMENT_ACCOUNT_CARD_BUTTON = "TANGEM_PAY_PAYMENT_ACCOUNT_CARD_BUTTON" + + // Card details (reveal + copy) + const val CARD_DETAILS_SHOW_BUTTON = "TANGEM_PAY_CARD_DETAILS_SHOW_BUTTON" + const val CARD_DETAILS_HIDE_BUTTON = "TANGEM_PAY_CARD_DETAILS_HIDE_BUTTON" + const val CARD_DETAILS_NUMBER_VALUE = "TANGEM_PAY_CARD_DETAILS_NUMBER_VALUE" + const val CARD_DETAILS_EXPIRATION_VALUE = "TANGEM_PAY_CARD_DETAILS_EXPIRATION_VALUE" + const val CARD_DETAILS_CVC_VALUE = "TANGEM_PAY_CARD_DETAILS_CVC_VALUE" + const val CARD_DETAILS_COPY_NUMBER = "TANGEM_PAY_CARD_DETAILS_COPY_NUMBER" + const val CARD_DETAILS_COPY_EXPIRATION = "TANGEM_PAY_CARD_DETAILS_COPY_EXPIRATION" + const val CARD_DETAILS_COPY_CVC = "TANGEM_PAY_CARD_DETAILS_COPY_CVC" + + // Card management (card page settings) + const val CHANGE_PIN_ROW = "TANGEM_PAY_CHANGE_PIN_ROW" + const val FREEZE_CARD_ROW = "TANGEM_PAY_FREEZE_CARD_ROW" + + // Freeze confirmation bottom sheet + const val FREEZE_CONFIRMATION_SUBMIT_BUTTON = "TANGEM_PAY_FREEZE_CONFIRMATION_SUBMIT_BUTTON" + + // PIN entry screen + const val PIN_SCREEN_TITLE = "TANGEM_PAY_PIN_SCREEN_TITLE" + const val PIN_SCREEN_DESCRIPTION = "TANGEM_PAY_PIN_SCREEN_DESCRIPTION" + const val PIN_INPUT_FIELD = "TANGEM_PAY_PIN_INPUT_FIELD" + const val PIN_SUBMIT_BUTTON = "TANGEM_PAY_PIN_SUBMIT_BUTTON" + const val PIN_ERROR_MESSAGE = "TANGEM_PAY_PIN_ERROR_MESSAGE" + + // PIN success screen + const val PIN_SUCCESS_TITLE = "TANGEM_PAY_PIN_SUCCESS_TITLE" + const val PIN_SUCCESS_DESCRIPTION = "TANGEM_PAY_PIN_SUCCESS_DESCRIPTION" + const val PIN_DONE_BUTTON = "TANGEM_PAY_PIN_DONE_BUTTON" +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/WarningBottomSheetTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/WarningBottomSheetTestTags.kt index f4e58b8056..d8c3f37a55 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/WarningBottomSheetTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/WarningBottomSheetTestTags.kt @@ -4,4 +4,6 @@ object WarningBottomSheetTestTags { const val ICON = "BASE_WARNING_BOTTOM_SHEET_ICON" const val TITLE = "BASE_WARNING_BOTTOM_SHEET_TITLE" const val MESSAGE = "BASE_WARNING_BOTTOM_SHEET_MESSAGE" + const val BUTTON_PRIMARY = "BASE_WARNING_BOTTOM_SHEET_BUTTON_PRIMARY" + const val BUTTON_SECONDARY = "BASE_WARNING_BOTTOM_SHEET_BUTTON_SECONDARY" } \ No newline at end of file diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 88d718323d..386c70565f 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -10,6 +10,16 @@ plugins { android { namespace = "com.tangem.data.visa" + + // `src/prodDi/` holds production DI bindings for TangemPay repos with a `mocked` counterpart. + // Wired into every build type EXCEPT `mocked`, which supplies its own bindings from `src/mocked/`. + buildTypes.configureEach { + if (name != "mocked") { + sourceSets.named(name) { + java.srcDir("src/prodDi/kotlin") + } + } + } } tasks.withType().configureEach { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 3187eeba11..7f41dabc9c 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -51,18 +51,10 @@ internal interface TangemPayDataModule { @Singleton fun bindKycRepository(repository: DefaultKycRepository): KycRepository - @Binds - @Singleton - fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository - @Binds @Singleton fun bindTangemPayTxHistoryRepository(repository: DefaultTangemPayTxHistoryRepository): TangemPayTxHistoryRepository - @Binds - @Singleton - fun bindCardDetailsRepository(repository: DefaultTangemPayCardDetailsRepository): TangemPayCardDetailsRepository - @Binds @Singleton fun bindTangemPaySwapRepository(repository: DefaultTangemPayWithdrawRepository): TangemPayWithdrawRepository diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/di/TangemPayDataMockedModule.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/di/TangemPayDataMockedModule.kt new file mode 100644 index 0000000000..16f3f0b9db --- /dev/null +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/di/TangemPayDataMockedModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.pay.di + +import com.tangem.data.pay.repository.MockAwareOnboardingRepository +import com.tangem.data.pay.repository.MockAwareTangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayDataMockedModule { + + @Binds + @Singleton + fun bindOnboardingRepository(repository: MockAwareOnboardingRepository): OnboardingRepository + + @Binds + @Singleton + fun bindCardDetailsRepository(repository: MockAwareTangemPayCardDetailsRepository): TangemPayCardDetailsRepository +} \ No newline at end of file diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt new file mode 100644 index 0000000000..9bc64e5bcf --- /dev/null +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt @@ -0,0 +1,110 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.right +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.CustomerInfo +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.visa.error.VisaApiError +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** In MOCK env skips local-storage / signing enrollment; server calls go to WireMock. */ +@Singleton +internal class MockAwareOnboardingRepository @Inject constructor( + private val real: DefaultOnboardingRepository, + private val apiConfigsManager: ApiConfigsManager, +) : OnboardingRepository { + + private val mockOrderIds: MutableSet = ConcurrentHashMap.newKeySet() + + private val isMockMode: Boolean + get() = apiConfigsManager + .getEnvironmentConfig(ApiConfig.ID.TangemPay) + .environment == ApiEnvironment.MOCK + + override suspend fun validateDeeplink(link: String): Either { + if (isMockMode) return true.right() + return real.validateDeeplink(link) + } + + override suspend fun isTangemPayInitialDataProduced(userWalletId: UserWalletId): Boolean { + if (isMockMode) return true + return real.isTangemPayInitialDataProduced(userWalletId) + } + + override suspend fun produceInitialData(userWalletId: UserWalletId) { + if (isMockMode) return + real.produceInitialData(userWalletId) + } + + override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either = + real.getCustomerInfo(userWalletId) + + override suspend fun createOrder(userWalletId: UserWalletId): Either { + if (isMockMode) { + mockOrderIds.add(userWalletId) + return MOCK_ORDER_ID.right() + } + return real.createOrder(userWalletId) + } + + override suspend fun clearOrderId(userWalletId: UserWalletId) { + if (isMockMode) { + mockOrderIds.remove(userWalletId) + return + } + real.clearOrderId(userWalletId) + } + + override suspend fun getOrderId(userWalletId: UserWalletId): String? { + if (isMockMode) return MOCK_ORDER_ID.takeIf { userWalletId in mockOrderIds } + return real.getOrderId(userWalletId) + } + + override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either = + real.hasTangemPayInWallet(userWalletId) + + override suspend fun checkCustomerEligibility(): List { + if (isMockMode) return listOf(TangemPayEligibilityType.DETAILS) + return real.checkCustomerEligibility() + } + + override suspend fun getCustomerEligibility(): List { + if (isMockMode) return listOf(TangemPayEligibilityType.DETAILS) + return real.getCustomerEligibility() + } + + override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? = + real.getSavedCustomerInfo(userWalletId) + + override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean { + if (isMockMode) return false + return real.getHideMainOnboardingBanner(userWalletId) + } + + override suspend fun setHideMainOnboardingBanner(userWalletId: UserWalletId) { + if (isMockMode) return + real.setHideMainOnboardingBanner(userWalletId) + } + + override suspend fun disableTangemPay(userWalletId: UserWalletId): Either { + if (isMockMode) return Unit.right() + return real.disableTangemPay(userWalletId) + } + + override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean { + if (isMockMode) return false + return real.isTangemPayDeactivated(userWalletId) + } + + private companion object { + const val MOCK_ORDER_ID = "mock-order-id" + } +} \ No newline at end of file diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt new file mode 100644 index 0000000000..fadd88988c --- /dev/null +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt @@ -0,0 +1,100 @@ +package com.tangem.data.pay.repository + +import arrow.core.Either +import arrow.core.right +import com.tangem.core.error.UniversalError +import com.tangem.datasource.api.common.config.ApiConfig +import com.tangem.datasource.api.common.config.ApiEnvironment +import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.domain.models.account.CardDisplayName +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.SetPinResult +import com.tangem.domain.pay.model.TangemPayCardBalance +import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** In MOCK env short-circuits RSA-encrypted flows (reveal/getPin/setPin) with hardcoded values. */ +@Singleton +internal class MockAwareTangemPayCardDetailsRepository @Inject constructor( + private val real: DefaultTangemPayCardDetailsRepository, + private val apiConfigsManager: ApiConfigsManager, +) : TangemPayCardDetailsRepository { + + private val isMockMode: Boolean + get() = apiConfigsManager + .getEnvironmentConfig(ApiConfig.ID.TangemPay) + .environment == ApiEnvironment.MOCK + + override suspend fun getCardBalance(userWalletId: UserWalletId): Either = + real.getCardBalance(userWalletId) + + override suspend fun revealCardDetails( + userWalletId: UserWalletId, + ): Either { + if (isMockMode) { + return TangemPayCardDetails( + pan = MOCK_PAN, + cvv = MOCK_CVV, + expirationYear = MOCK_EXPIRATION_YEAR, + expirationMonth = MOCK_EXPIRATION_MONTH, + ).right() + } + return real.revealCardDetails(userWalletId) + } + + override suspend fun getPin(userWalletId: UserWalletId, cardId: String): Either { + if (isMockMode) return MOCK_PIN.right() + return real.getPin(userWalletId, cardId) + } + + override suspend fun setPin(userWalletId: UserWalletId, pin: String): Either { + if (isMockMode) return SetPinResult.SUCCESS.right() + return real.setPin(userWalletId, pin) + } + + override suspend fun isAddToWalletDone(userWalletId: UserWalletId): Either = + real.isAddToWalletDone(userWalletId) + + override suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either = + real.setAddToWalletAsDone(userWalletId) + + override suspend fun freezeCard( + userWalletId: UserWalletId, + cardId: String, + ): Either = real.freezeCard(userWalletId, cardId) + + override suspend fun unfreezeCard( + userWalletId: UserWalletId, + cardId: String, + ): Either = real.unfreezeCard(userWalletId, cardId) + + override fun cardFrozenState(cardId: String): Flow = + real.cardFrozenState(cardId) + + override suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? = + real.cardFrozenStateSync(cardId) + + override suspend fun updateCardDisplayName( + cardId: String, + userWalletId: UserWalletId, + displayName: CardDisplayName, + ): Either = real.updateCardDisplayName(cardId, userWalletId, displayName) + + override suspend fun updateCardLimit( + cardId: String, + userWalletId: UserWalletId, + limit: String, + ): Either = real.updateCardLimit(cardId, userWalletId, limit) + + private companion object { + const val MOCK_PAN = "4242 4242 4242 4242" + const val MOCK_CVV = "123" + const val MOCK_EXPIRATION_YEAR = "2028" + const val MOCK_EXPIRATION_MONTH = "12" + const val MOCK_PIN = "1234" + } +} \ No newline at end of file diff --git a/data/visa/src/prodDi/kotlin/com/tangem/data/pay/di/TangemPayDataProductionModule.kt b/data/visa/src/prodDi/kotlin/com/tangem/data/pay/di/TangemPayDataProductionModule.kt new file mode 100644 index 0000000000..02c95e98be --- /dev/null +++ b/data/visa/src/prodDi/kotlin/com/tangem/data/pay/di/TangemPayDataProductionModule.kt @@ -0,0 +1,24 @@ +package com.tangem.data.pay.di + +import com.tangem.data.pay.repository.DefaultOnboardingRepository +import com.tangem.data.pay.repository.DefaultTangemPayCardDetailsRepository +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface TangemPayDataProductionModule { + + @Binds + @Singleton + fun bindOnboardingRepository(repository: DefaultOnboardingRepository): OnboardingRepository + + @Binds + @Singleton + fun bindCardDetailsRepository(repository: DefaultTangemPayCardDetailsRepository): TangemPayCardDetailsRepository +} \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt index 0be023b18b..ca7747292e 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/accesscode/ui/AccessCode.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -22,6 +23,7 @@ import com.tangem.core.ui.haptic.TangemHapticEffect import com.tangem.core.ui.res.LocalHapticManager import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.HotWalletAccessCodeTestTags import com.tangem.features.hotwallet.accesscode.entity.AccessCodeUM import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay @@ -109,6 +111,7 @@ internal fun AccessCode( pinTextColor = state.accessCodeColor, onValueChange = state.onAccessCodeChange, focusRequester = focusRequester, + modifier = Modifier.testTag(HotWalletAccessCodeTestTags.ACCESS_CODE_INPUT), ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt index 77b7bab3d6..c70080b726 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardPageUM.kt @@ -36,5 +36,6 @@ internal data class TangemPayCardPageUM( @Immutable internal data class TangemPayCardPageSetting( val title: TextReference, + val testTag: String? = null, val onSettingClick: () -> Unit, ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 59b353136e..45a98d4156 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -117,10 +117,12 @@ internal class TangemPayCardPageModel @Inject constructor( TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_change_pin), onSettingClick = { onClickChangePIN(card.hasPinCode) }, + testTag = com.tangem.core.ui.test.TangemPayTestTags.CHANGE_PIN_ROW, ), TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_freeze_card), onSettingClick = { onClickFreezeOrUnfreezeCard(card.isFrozen) }, + testTag = com.tangem.core.ui.test.TangemPayTestTags.FREEZE_CARD_ROW, ), TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_reissue_card), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index d2feacdda6..efe8c8a8dd 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction @@ -49,6 +50,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R @@ -209,10 +211,12 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif } TangemPayCardDetailsCustomButton( - modifier = Modifier.constrainAs(buttonRef) { - end.linkTo(parent.end) - bottom.linkTo(parent.bottom) - }, + modifier = Modifier + .constrainAs(buttonRef) { + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + } + .testTag(TangemPayTestTags.CARD_DETAILS_SHOW_BUTTON), text = stringResourceSafe(id = R.string.tangempay_card_details_show_details), onClick = state.onClick, showProgress = state.isLoading, @@ -336,6 +340,8 @@ private fun TangemPayCardDetailsShownBlock( title = stringResourceSafe(R.string.tangempay_card_details_card_number), text = cardNumber, onCopy = onCopyCardNumber, + valueTestTag = TangemPayTestTags.CARD_DETAILS_NUMBER_VALUE, + copyTestTag = TangemPayTestTags.CARD_DETAILS_COPY_NUMBER, ) Row( modifier = Modifier @@ -350,6 +356,8 @@ private fun TangemPayCardDetailsShownBlock( title = stringResourceSafe(R.string.tangempay_card_details_expiry), text = expiry, onCopy = onCopyExpiry, + valueTestTag = TangemPayTestTags.CARD_DETAILS_EXPIRATION_VALUE, + copyTestTag = TangemPayTestTags.CARD_DETAILS_COPY_EXPIRATION, ) CardDetailsTextContainer( modifier = Modifier @@ -358,13 +366,17 @@ private fun TangemPayCardDetailsShownBlock( title = stringResourceSafe(R.string.tangempay_card_details_cvc), text = cvv, onCopy = onCopyCvv, + valueTestTag = TangemPayTestTags.CARD_DETAILS_CVC_VALUE, + copyTestTag = TangemPayTestTags.CARD_DETAILS_COPY_CVC, ) } Spacer(modifier = Modifier.weight(1f)) Row { SpacerWMax() TangemPayCardDetailsCustomButton( - modifier = Modifier.padding(end = 16.dp, bottom = 8.dp), + modifier = Modifier + .padding(end = 16.dp, bottom = 8.dp) + .testTag(TangemPayTestTags.CARD_DETAILS_HIDE_BUTTON), text = stringResourceSafe(id = R.string.tangempay_card_details_hide_details), onClick = onHideDetails, showProgress = false, @@ -374,7 +386,14 @@ private fun TangemPayCardDetailsShownBlock( } @Composable -private fun CardDetailsTextContainer(title: String, text: String, onCopy: () -> Unit, modifier: Modifier = Modifier) { +private fun CardDetailsTextContainer( + title: String, + text: String, + onCopy: () -> Unit, + modifier: Modifier = Modifier, + valueTestTag: String? = null, + copyTestTag: String? = null, +) { Row( modifier = modifier .background( @@ -395,10 +414,13 @@ private fun CardDetailsTextContainer(title: String, text: String, onCopy: () -> text = text, style = TangemTheme.typography.body2, color = TangemTheme.colors.text.constantWhite, + modifier = if (valueTestTag != null) Modifier.testTag(valueTestTag) else Modifier, ) } IconButton( - modifier = Modifier.size(TangemTheme.dimens.size32), + modifier = Modifier + .size(TangemTheme.dimens.size32) + .then(if (copyTestTag != null) Modifier.testTag(copyTestTag) else Modifier), onClick = onCopy, ) { Icon( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index a9a05b551e..4b8dbc7446 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.appbar.AppBarWithBackButton @@ -169,7 +170,8 @@ private fun TangemPayCardPageSettingRow( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(TangemTheme.dimens.spacing12), + .padding(TangemTheme.dimens.spacing12) + .then(if (item.testTag != null) Modifier.testTag(item.testTag) else Modifier), contentAlignment = Alignment.CenterStart, ) { Text( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt index d1c902a313..eda90333b9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -23,6 +24,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.features.tangempay.details.impl.R @Composable @@ -43,7 +45,7 @@ internal fun TangemPayChangePinCodeSuccessScreen(onClick: () -> Unit, modifier: titleAlignment = Alignment.CenterHorizontally, ) Column( - modifier + modifier = Modifier .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -58,7 +60,8 @@ internal fun TangemPayChangePinCodeSuccessScreen(onClick: () -> Unit, modifier: .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = 16.dp) - .navigationBarsPadding(), + .navigationBarsPadding() + .testTag(TangemPayTestTags.PIN_DONE_BUTTON), text = stringResourceSafe(R.string.common_done), onClick = onClick, ) @@ -101,14 +104,18 @@ private fun SuccessContent(modifier: Modifier = Modifier) { ) SpacerH32() Text( - modifier = Modifier.padding(horizontal = 32.dp), + modifier = Modifier + .padding(horizontal = 32.dp) + .testTag(TangemPayTestTags.PIN_SUCCESS_TITLE), text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_title), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, ) SpacerH12() Text( - modifier = Modifier.padding(horizontal = 32.dp), + modifier = Modifier + .padding(horizontal = 32.dp) + .testTag(TangemPayTestTags.PIN_SUCCESS_DESCRIPTION), text = stringResourceSafe(R.string.tangempay_card_details_change_pin_success_description), style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt index c1eaca6abc..88a3a9ca2e 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color.Companion.Transparent import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -31,6 +32,7 @@ import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayChangePinUM import kotlinx.coroutines.delay @@ -52,7 +54,7 @@ internal fun TangemPayChangePinScreen( ) Column( - modifier = modifier + modifier = Modifier .fillMaxWidth() .padding(top = 48.dp) .padding(horizontal = 36.dp) @@ -64,6 +66,7 @@ internal fun TangemPayChangePinScreen( style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, textAlign = TextAlign.Center, + modifier = Modifier.testTag(TangemPayTestTags.PIN_SCREEN_TITLE), ) SpacerH16() @@ -73,6 +76,7 @@ internal fun TangemPayChangePinScreen( style = TangemTheme.typography.body1, color = TangemTheme.colors.text.secondary, textAlign = TextAlign.Center, + modifier = Modifier.testTag(TangemPayTestTags.PIN_SCREEN_DESCRIPTION), ) SpacerH(26.dp) @@ -84,7 +88,8 @@ internal fun TangemPayChangePinScreen( modifier = Modifier .imePadding() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) - .fillMaxWidth(), + .fillMaxWidth() + .testTag(TangemPayTestTags.PIN_SUBMIT_BUTTON), primaryButton = NavigationButton( textReference = resourceReference(R.string.common_submit), onClick = state.onSubmitClick, @@ -117,6 +122,7 @@ private fun PinCodeSection(state: TangemPayChangePinUM, modifier: Modifier = Mod style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.warning, textAlign = TextAlign.Center, + modifier = Modifier.testTag(TangemPayTestTags.PIN_ERROR_MESSAGE), ) } } @@ -150,7 +156,8 @@ private fun PinCode( .clickable { focusRequester.requestFocus() keyboardController?.show() - }, + } + .testTag(TangemPayTestTags.PIN_INPUT_FIELD), textStyle = TangemTheme.typography.h1.copy(color = Transparent), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 938b054d98..2349905b14 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -49,6 +49,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.core.ui.test.TokenDetailsTopBarTestTags import com.tangem.core.ui.test.WalletConnectBottomSheetTestTags import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTransactionsComponent @@ -304,7 +305,7 @@ private fun FiatBalance( ), ) is TangemPayDetailsBalanceBlockState.Content -> Text( - modifier = modifier, + modifier = modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), text = state.fiatBalance.orMaskWithStars(isBalanceHidden), style = TangemTheme.typography.h2.applyBladeBrush( isEnabled = state.isBalanceFlickering, @@ -312,7 +313,7 @@ private fun FiatBalance( ), ) is TangemPayDetailsBalanceBlockState.Error -> Text( - modifier = modifier, + modifier = modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE), text = DASH_SIGN.orMaskWithStars(isBalanceHidden), style = TangemTheme.typography.h2, color = TangemTheme.colors.text.primary1, diff --git a/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContent.kt b/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContent.kt index 54da2fa3ec..deecec789f 100644 --- a/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContent.kt +++ b/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContent.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign @@ -31,6 +32,7 @@ import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.features.tangempay.entity.TangemPayMainUM import com.tangem.features.tangempay.main.impl.R import com.tangem.utils.StringsSigns.DASH_SIGN @@ -90,7 +92,8 @@ private fun TangemPayMainContent( modifier = modifier .clip(RoundedCornerShape(size = 18.dp)) .background(TangemTheme.colors2.surface.level3) - .clickableSingle(onClick = payMainUM.onClick), + .clickableSingle(onClick = payMainUM.onClick) + .testTag(TangemPayTestTags.MAIN_SCREEN_TILE), ) { Image( painter = painterResource(R.drawable.img_visa_36), diff --git a/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContentLegacy.kt b/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContentLegacy.kt index e95dcc262b..f72aecb6f4 100644 --- a/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContentLegacy.kt +++ b/features/tangempay/main/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayMainBlockContentLegacy.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -29,6 +30,7 @@ import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.features.tangempay.entity.TangemPayMainUM import com.tangem.features.tangempay.main.impl.R import com.tangem.utils.StringsSigns.DASH_SIGN @@ -57,7 +59,7 @@ private fun TangemPayMainBlockContent( modifier: Modifier = Modifier, ) { Surface( - modifier = modifier, + modifier = modifier.testTag(TangemPayTestTags.MAIN_SCREEN_TILE), shape = TangemTheme.shapes.roundedCornersXMedium, color = TangemTheme.colors.background.primary, onClick = state.onClick, From 3398db66e2e4cdb26baa2aa1e65e14c8a8f689e5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 27 Apr 2026 14:34:43 +0300 Subject: [PATCH 138/206] Updated on 2026-08-14 --- .../transactions/TransactionList.kt | 20 +- .../empty/EmptyTransactionBlock.kt | 118 +++++--- .../empty/EmptyTransactionBlockLegacy.kt | 129 ++++++++ .../empty/EmptyTransactionsBlockState.kt | 21 +- .../DefaultTokenDetailsComponent.kt | 1 + .../tokendetails/ui/TokenDetailsScreen.kt | 28 +- .../ui/TokenDetailsScreenLegacy.kt | 6 +- features/txhistory/api/build.gradle.kts | 1 + .../txhistory/component/TxHistoryComponent.kt | 2 + .../features/txhistory/entity/TxHistoryUM.kt | 1 + .../features/txhistory/ui/TxHistoryContent.kt | 283 +++++++++++------- .../txhistory/ui/TxHistoryContentLegacy.kt | 150 ++++++++++ .../component/DefaultTxHistoryComponent.kt | 5 + 13 files changed, 609 insertions(+), 156 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlockLegacy.kt create mode 100644 features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContentLegacy.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt index b5c5335aba..d1b253f7d9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionList.kt @@ -14,7 +14,8 @@ import androidx.compose.ui.util.fastForEach import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey -import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlock +import com.tangem.core.ui.R +import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlockLegacy import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState import com.tangem.core.ui.components.transactions.state.TransactionState import com.tangem.core.ui.components.transactions.state.TxHistoryState @@ -45,13 +46,21 @@ fun LazyListScope.txHistoryItems( ) } is TxHistoryState.Empty -> { - nonContentItem(state = EmptyTransactionsBlockState.Empty(state.onExploreClick), modifier = modifier) + nonContentItem( + state = EmptyTransactionsBlockState.Empty( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + modifier = modifier, + ) } is TxHistoryState.Error -> { nonContentItem( state = EmptyTransactionsBlockState.FailedToLoad( onReload = state.onReloadClick, onExplore = state.onExploreClick, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_arrow_top_right_24, ), modifier = modifier, ) @@ -64,7 +73,10 @@ fun LazyListScope.txHistoryItems( } nonContentItem( - state = EmptyTransactionsBlockState.NotImplemented(onExplore = state.onExploreClick), + state = EmptyTransactionsBlockState.NotImplemented( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), modifier = modifier, ) } @@ -121,7 +133,7 @@ fun PendingTxsBlock(pendingTxs: ImmutableList, isBalanceHidden private fun LazyListScope.nonContentItem(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { item(key = state::class.java, contentType = state::class.java) { - EmptyTransactionBlock( + EmptyTransactionBlockLegacy( state = state, modifier = modifier .animateItem(fadeInSpec = null, fadeOutSpec = null) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlock.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlock.kt index dfb6f1339c..0b672e80b2 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlock.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlock.kt @@ -1,65 +1,74 @@ package com.tangem.core.ui.components.transactions.empty import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.components.buttons.actions.ActionButton +import com.tangem.core.ui.R +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.ds.button.TangemButton +import com.tangem.core.ui.ds.button.TangemButtonIconPosition +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.EmptyTransactionBlockTestTags -/** - * Placeholder for transaction's block without content - * - * @param state component state - * @param modifier modifier - */ @Composable fun EmptyTransactionBlock(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { Column( modifier = modifier - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(color = TangemTheme.colors.background.primary) - .padding(vertical = TangemTheme.dimens.spacing24) + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x6) .testTag(EmptyTransactionBlockTestTags.BLOCK), - verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24), horizontalAlignment = Alignment.CenterHorizontally, ) { - Icon( + TangemIcon( + tangemIconUM = TangemIconUM.Icon( + iconRes = state.iconRes, + tintReference = { TangemTheme.colors2.graphic.neutral.tertiary }, + ), modifier = Modifier - .size(TangemTheme.dimens.size64) + .size(TangemTheme.dimens2.x16) .testTag(EmptyTransactionBlockTestTags.ICON), - painter = painterResource(id = state.iconRes), - tint = TangemTheme.colors.icon.inactive, - contentDescription = null, ) + Spacer(modifier = Modifier.height(TangemTheme.dimens2.x4)) + Text( modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing32) + .padding(horizontal = TangemTheme.dimens2.x8) .testTag(EmptyTransactionBlockTestTags.TEXT), textAlign = TextAlign.Center, text = state.text.resolveReference(), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.tertiary, + style = TangemTheme.typography2.calloutRegular15, + color = TangemTheme.colors2.text.neutral.tertiary, ) + Spacer(modifier = Modifier.height(TangemTheme.dimens2.x8)) + Buttons( modifier = Modifier - .padding(horizontal = TangemTheme.dimens.spacing18) + .padding(horizontal = TangemTheme.dimens2.x4) .testTag(EmptyTransactionBlockTestTags.EXPLORE_BUTTON), state = state.buttonsState, ) @@ -76,41 +85,70 @@ private fun Buttons(state: EmptyTransactionsBlockState.ButtonsState, modifier: M @Composable private fun SingleButton(state: EmptyTransactionsBlockState.ButtonsState.SingleButton, modifier: Modifier = Modifier) { - ActionButton(modifier = modifier, config = state.actionButtonConfig) + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Center, + ) { + TangemButton(buttonUM = state.actionButtonConfig.toButtonUM()) + } } @Composable private fun PairButtons(state: EmptyTransactionsBlockState.ButtonsState.PairButtons, modifier: Modifier = Modifier) { Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), ) { - ActionButton( + TangemButton( modifier = Modifier.weight(1F), - config = state.firstButtonConfig, + buttonUM = state.firstButtonConfig.toButtonUM(), ) - ActionButton( + TangemButton( modifier = Modifier.weight(1F), - config = state.secondButtonConfig, + buttonUM = state.secondButtonConfig.toButtonUM(), ) } } +private fun ActionButtonConfig.toButtonUM(): TangemButtonUM = TangemButtonUM( + text = text, + tangemIconUM = TangemIconUM.Icon(iconRes = iconResId), + type = TangemButtonType.Secondary, + size = TangemButtonSize.X12, + isEnabled = isEnabled, + isLoading = isInProgress, + onClick = onClick, + shape = TangemButtonShape.Rounded, + iconPosition = TangemButtonIconPosition.End, +) + @Composable @Preview(widthDp = 360, showBackground = true) @Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun EmptyTransactionBlockPreview( @PreviewParameter(EmptyTransactionBlockStateProvider::class) state: EmptyTransactionsBlockState, ) { - TangemThemePreview { + TangemThemePreviewRedesign { EmptyTransactionBlock(state = state) } } -private class EmptyTransactionBlockStateProvider : CollectionPreviewParameterProvider( - collection = listOf( - EmptyTransactionsBlockState.Empty {}, - EmptyTransactionsBlockState.FailedToLoad(onReload = {}, onExplore = {}), - EmptyTransactionsBlockState.NotImplemented(onExplore = {}), - ), -) \ No newline at end of file +private class EmptyTransactionBlockStateProvider : + CollectionPreviewParameterProvider( + collection = listOf( + EmptyTransactionsBlockState.Empty( + onExplore = {}, + exploreIconResId = R.drawable.ic_compass_24, + ), + EmptyTransactionsBlockState.FailedToLoad( + onReload = {}, + onExplore = {}, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_compass_24, + ), + EmptyTransactionsBlockState.NotImplemented( + onExplore = {}, + exploreIconResId = R.drawable.ic_compass_24, + ), + ), + ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlockLegacy.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlockLegacy.kt new file mode 100644 index 0000000000..c4cfe226f2 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionBlockLegacy.kt @@ -0,0 +1,129 @@ +package com.tangem.core.ui.components.transactions.empty + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import com.tangem.core.ui.R +import com.tangem.core.ui.components.buttons.actions.ActionButton +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.EmptyTransactionBlockTestTags + +/** + * Placeholder for transaction's block without content + * + * @param state component state + * @param modifier modifier + */ +@Composable +fun EmptyTransactionBlockLegacy(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(TangemTheme.shapes.roundedCornersXMedium) + .background(color = TangemTheme.colors.background.primary) + .padding(vertical = TangemTheme.dimens.spacing24) + .testTag(EmptyTransactionBlockTestTags.BLOCK), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens.size64) + .testTag(EmptyTransactionBlockTestTags.ICON), + painter = painterResource(id = state.iconRes), + tint = TangemTheme.colors.icon.inactive, + contentDescription = null, + ) + + Text( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing32) + .testTag(EmptyTransactionBlockTestTags.TEXT), + textAlign = TextAlign.Center, + text = state.text.resolveReference(), + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.tertiary, + ) + + Buttons( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens.spacing18) + .testTag(EmptyTransactionBlockTestTags.EXPLORE_BUTTON), + state = state.buttonsState, + ) + } +} + +@Composable +private fun Buttons(state: EmptyTransactionsBlockState.ButtonsState, modifier: Modifier = Modifier) { + when (state) { + is EmptyTransactionsBlockState.ButtonsState.SingleButton -> SingleButton(state = state, modifier = modifier) + is EmptyTransactionsBlockState.ButtonsState.PairButtons -> PairButtons(state = state, modifier = modifier) + } +} + +@Composable +private fun SingleButton(state: EmptyTransactionsBlockState.ButtonsState.SingleButton, modifier: Modifier = Modifier) { + ActionButton(modifier = modifier, config = state.actionButtonConfig) +} + +@Composable +private fun PairButtons(state: EmptyTransactionsBlockState.ButtonsState.PairButtons, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing8), + ) { + ActionButton( + modifier = Modifier.weight(1F), + config = state.firstButtonConfig, + ) + ActionButton( + modifier = Modifier.weight(1F), + config = state.secondButtonConfig, + ) + } +} + +@Composable +@Preview(widthDp = 360, showBackground = true) +@Preview(widthDp = 360, showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun EmptyTransactionBlockLegacyPreview( + @PreviewParameter(EmptyTransactionBlockLegacyStateProvider::class) state: EmptyTransactionsBlockState, +) { + TangemThemePreview { + EmptyTransactionBlockLegacy(state = state) + } +} + +private class EmptyTransactionBlockLegacyStateProvider : + CollectionPreviewParameterProvider( + collection = listOf( + EmptyTransactionsBlockState.Empty( + onExplore = {}, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + EmptyTransactionsBlockState.FailedToLoad( + onReload = {}, + onExplore = {}, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + EmptyTransactionsBlockState.NotImplemented( + onExplore = {}, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + ), + ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionsBlockState.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionsBlockState.kt index 53790df155..a39b110c46 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionsBlockState.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/empty/EmptyTransactionsBlockState.kt @@ -1,5 +1,6 @@ package com.tangem.core.ui.components.transactions.empty +import androidx.annotation.DrawableRes import com.tangem.core.ui.R import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.extensions.TextReference @@ -21,17 +22,19 @@ sealed class EmptyTransactionsBlockState( data class FailedToLoad( val onReload: () -> Unit, val onExplore: () -> Unit, + @DrawableRes val reloadIconResId: Int, + @DrawableRes val exploreIconResId: Int, ) : EmptyTransactionsBlockState( buttonsState = ButtonsState.PairButtons( firstButtonConfig = ActionButtonConfig( text = TextReference.Res(R.string.common_reload), - iconResId = R.drawable.ic_refresh_24, + iconResId = reloadIconResId, onClick = onReload, isEnabled = true, ), secondButtonConfig = ActionButtonConfig( text = TextReference.Res(R.string.common_explore), - iconResId = R.drawable.ic_arrow_top_right_24, + iconResId = exploreIconResId, onClick = onExplore, isEnabled = true, ), @@ -40,11 +43,14 @@ sealed class EmptyTransactionsBlockState( text = TextReference.Res(R.string.transaction_history_error_failed_to_load), ) - data class Empty(val onExplore: (() -> Unit)) : EmptyTransactionsBlockState( + data class Empty( + val onExplore: () -> Unit, + @DrawableRes val exploreIconResId: Int, + ) : EmptyTransactionsBlockState( buttonsState = ButtonsState.SingleButton( actionButtonConfig = ActionButtonConfig( text = TextReference.Res(R.string.common_explore), - iconResId = R.drawable.ic_arrow_top_right_24, + iconResId = exploreIconResId, onClick = onExplore, isEnabled = true, ), @@ -53,11 +59,14 @@ sealed class EmptyTransactionsBlockState( text = TextReference.Res(R.string.transaction_history_empty_transactions), ) - data class NotImplemented(val onExplore: () -> Unit) : EmptyTransactionsBlockState( + data class NotImplemented( + val onExplore: () -> Unit, + @DrawableRes val exploreIconResId: Int, + ) : EmptyTransactionsBlockState( buttonsState = ButtonsState.SingleButton( actionButtonConfig = ActionButtonConfig( text = TextReference.Res(R.string.common_explore_transaction_history), - iconResId = R.drawable.ic_arrow_top_right_24, + iconResId = exploreIconResId, onClick = onExplore, isEnabled = true, ), diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index c2f0d12ba7..14d9bddff3 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -99,6 +99,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( tokenDetailsUM = tokenDetailsUM, tokenMarketBlockComponent = tokenMarketBlockComponent, yieldSupplyComponent = yieldSupplyComponent, + txHistoryComponent = txHistoryComponent, modifier = modifier, ) } else { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt index ac99df1076..03668be768 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreen.kt @@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import com.tangem.common.ui.earn.EarnBlock import com.tangem.common.ui.notifications.notifications @@ -21,6 +24,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -56,10 +60,14 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight import com.tangem.features.markets.token.block.TokenMarketBlockComponent +import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.entity.TxHistoryUM import com.tangem.features.yield.supply.api.YieldSupplyComponent import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeTint import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow private val TopBarHeight: Dp = 64.dp private val MarketBlockHorizontalPadding: Dp = 14.dp @@ -69,6 +77,7 @@ internal fun TokenDetailsScreen( tokenDetailsUM: TokenDetailsUM, tokenMarketBlockComponent: TokenMarketBlockComponent?, yieldSupplyComponent: YieldSupplyComponent, + txHistoryComponent: TxHistoryComponent, modifier: Modifier = Modifier, ) { val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() } @@ -110,6 +119,7 @@ internal fun TokenDetailsScreen( TokenDetailsBody( tokenDetailsUM = tokenDetailsUM, yieldSupplyComponent = yieldSupplyComponent, + txHistoryComponent = txHistoryComponent, rootBackground = rootBackground, bottomContentPadding = marketBlockHeight, modifier = Modifier @@ -197,13 +207,18 @@ private fun BoxScope.TokenDetailsMarketBlockOverlay( private fun TokenDetailsBody( tokenDetailsUM: TokenDetailsUM, yieldSupplyComponent: YieldSupplyComponent, + txHistoryComponent: TxHistoryComponent, rootBackground: Color, bottomContentPadding: Dp, modifier: Modifier = Modifier, itemModifier: Modifier = Modifier, ) { + val listState = rememberLazyListState() + val txHistoryState by txHistoryComponent.txHistoryState.collectAsStateWithLifecycle() + LazyColumn( modifier = modifier, + state = listState, contentPadding = PaddingValues(bottom = bottomContentPadding), ) { notifications( @@ -222,7 +237,9 @@ private fun TokenDetailsBody( item(key = "yield_supply_block") { yieldSupplyComponent.Content(modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x2)) } - // TODO [REDACTED_TASK_KEY] Token Details Make Transaction History + with(txHistoryComponent) { + txHistoryContent(listState = listState, state = txHistoryState) + } } } @@ -273,6 +290,15 @@ private fun TokenDetailsScreen_Preview() { @Composable override fun Content(modifier: Modifier) = Unit }, + txHistoryComponent = object : TxHistoryComponent { + override val txHistoryState: StateFlow = MutableStateFlow( + value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}), + ) + + override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) = Unit + + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) = Unit + }, ) } } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index 4a1b464543..fed9925378 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -151,7 +151,9 @@ internal fun TokenDetailsScreenLegacy( modifier = itemModifier, ) - with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryComponentState) } + with(txHistoryComponent) { + txHistoryContentLegacy(listState = listState, state = txHistoryComponentState) + } } } @@ -179,6 +181,8 @@ private fun TokenDetailsScreenPreview( value = TxHistoryUM.Empty(isBalanceHidden = false, onExploreClick = {}), ) + override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) = Unit + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) = Unit }, yieldSupplyComponent = object : YieldSupplyComponent { diff --git a/features/txhistory/api/build.gradle.kts b/features/txhistory/api/build.gradle.kts index 269986e650..33ac80bd64 100644 --- a/features/txhistory/api/build.gradle.kts +++ b/features/txhistory/api/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { /** Compose */ implementation(deps.compose.runtime) implementation(deps.compose.foundation) + implementation(deps.compose.ui.tooling) /** Other */ implementation(deps.kotlin.immutable.collections) diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt index 470bb333f5..a18865d1d5 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt @@ -14,6 +14,8 @@ interface TxHistoryComponent { val txHistoryState: StateFlow + fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) + fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) data class Params( diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt index ad33da1dc0..dca5fc31be 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryUM.kt @@ -15,6 +15,7 @@ sealed interface TxHistoryUM { TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_1")), TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_2")), TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_3")), + TxHistoryItemUM.Transaction(TransactionState.Loading("LOADING_TX_HASH_4")), ) } diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt index aafd528437..56637babd7 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContent.kt @@ -1,139 +1,214 @@ package com.tangem.features.txhistory.ui +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.tangem.core.ui.components.list.InfiniteListHandler -import com.tangem.core.ui.components.transactions.PendingTxsBlock -import com.tangem.core.ui.components.transactions.Transaction -import com.tangem.core.ui.components.transactions.TxHistoryGroupTitle -import com.tangem.core.ui.components.transactions.TxHistoryTitle +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.CircleShimmer +import com.tangem.core.ui.components.RectangleShimmer import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlock import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState -import com.tangem.core.ui.components.transactions.state.TransactionState -import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.ds.row.TangemRowContainer +import com.tangem.core.ui.ds.row.TangemRowLayoutId import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.txhistory.entity.TxHistoryUM -private const val LOAD_ITEMS_BUFFER = 20 +private val LoadingTitleShimmerWidth = 52.dp +private val LoadingPrimaryShimmerWidth = 110.dp +private val LoadingSecondaryShimmerWidth = 52.dp +private val LoadingEndTopShimmerWidth = 107.dp +private val LoadingEndBottomShimmerWidth = 52.dp + +private const val LOADING_TRANSACTION_MIN_ALPHA = 0.1f fun LazyListScope.txHistoryItems(listState: LazyListState, state: TxHistoryUM) { when (state) { is TxHistoryUM.Content -> contentItems(listState, state) - is TxHistoryUM.Empty -> nonContentItem(state = EmptyTransactionsBlockState.Empty(state.onExploreClick)) - is TxHistoryUM.Error -> nonContentItem( - state = EmptyTransactionsBlockState.FailedToLoad( - onReload = state.onReloadClick, - onExplore = state.onExploreClick, - ), - ) + is TxHistoryUM.Empty -> emptyItem(state) + is TxHistoryUM.Error -> errorItem(state) is TxHistoryUM.Loading -> loadingItems(state) - is TxHistoryUM.NotSupported -> { - if (state.pendingTransactions.isNotEmpty()) { - item(key = "PendingTxsBlock", contentType = "PendingTxsBlock") { - PendingTxsBlock(pendingTxs = state.pendingTransactions, isBalanceHidden = state.isBalanceHidden) - } - } - - nonContentItem( - state = EmptyTransactionsBlockState.NotImplemented(onExplore = state.onExploreClick), - ) - } + is TxHistoryUM.NotSupported -> notSupportedItem(state) } } -private fun LazyListScope.nonContentItem(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { - item(key = state::class.java, contentType = state::class.java) { - EmptyTransactionBlock( - state = state, - modifier = modifier - .animateItem(fadeInSpec = null, fadeOutSpec = null) - .padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing12) - .fillMaxWidth(), - ) +@Suppress("UNUSED_PARAMETER") +private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryUM.Content) { + item(key = "tx_history_content", contentType = "tx_history_content") { + TxHistoryContentBlock(state = state) + } +} + +private fun LazyListScope.emptyItem(state: TxHistoryUM.Empty) { + item(key = "tx_history_empty", contentType = "tx_history_empty") { + TxHistoryEmptyBlock(state = state) + } +} + +private fun LazyListScope.errorItem(state: TxHistoryUM.Error) { + item(key = "tx_history_error", contentType = "tx_history_error") { + TxHistoryErrorBlock(state = state) } } private fun LazyListScope.loadingItems(state: TxHistoryUM.Loading) { - itemsIndexed( - items = state.items, - key = { _, item -> - when (item) { - is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey - is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() - is TxHistoryUM.TxHistoryItemUM.Transaction -> - item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() - } - }, - contentType = { _, item -> item::class.java }, - itemContent = { index, item -> - TxHistoryListItem( - state = item, - isBalanceHidden = true, - modifier = Modifier.roundedShapeItemDecoration( - currentIndex = index, - lastIndex = state.items.lastIndex, - ), - ) - }, + item(key = "tx_history_loading", contentType = "tx_history_loading") { + TxHistoryLoadingBlock(state = state) + } +} + +private fun LazyListScope.notSupportedItem(state: TxHistoryUM.NotSupported) { + item(key = "tx_history_not_supported", contentType = "tx_history_not_supported") { + TxHistoryNotSupportedBlock(state = state) + } +} + +@Suppress("UNUSED_PARAMETER") +@Composable +private fun TxHistoryContentBlock(state: TxHistoryUM.Content, modifier: Modifier = Modifier) { + // TODO [REDACTED_TASK_KEY] redesign Content state +} + +@Composable +private fun TxHistoryEmptyBlock(state: TxHistoryUM.Empty, modifier: Modifier = Modifier) { + EmptyTransactionBlock( + state = EmptyTransactionsBlockState.Empty( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_compass_24, + ), + modifier = modifier, ) } -private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryUM.Content) { - itemsIndexed( - items = state.items, - key = { _, item -> - when (item) { - is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey - is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() - is TxHistoryUM.TxHistoryItemUM.Transaction -> - item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() - } - }, - contentType = { _, item -> item::class.java }, - itemContent = { index, item -> - TxHistoryListItem( - state = item, - isBalanceHidden = state.isBalanceHidden, - modifier = Modifier.roundedShapeItemDecoration( - currentIndex = index, - lastIndex = state.items.lastIndex, - ), - ) - }, +@Composable +private fun TxHistoryErrorBlock(state: TxHistoryUM.Error, modifier: Modifier = Modifier) { + EmptyTransactionBlock( + state = EmptyTransactionsBlockState.FailedToLoad( + onReload = state.onReloadClick, + onExplore = state.onExploreClick, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_compass_24, + ), + modifier = modifier, ) - item { - InfiniteListHandler( - listState = listState, - buffer = LOAD_ITEMS_BUFFER, - onLoadMore = state.loadMore, - ) +} + +@Composable +private fun TxHistoryLoadingBlock(state: TxHistoryUM.Loading, modifier: Modifier = Modifier) { + val transactionCount = state.items.count { it is TxHistoryUM.TxHistoryItemUM.Transaction } + Column(modifier = modifier.fillMaxWidth()) { + var transactionIndex = 0 + state.items.forEach { item -> + when (item) { + is TxHistoryUM.TxHistoryItemUM.Title -> TxHistoryLoadingTitle() + is TxHistoryUM.TxHistoryItemUM.Transaction -> { + val fraction = if (transactionCount <= 1) { + 0f + } else { + transactionIndex.toFloat() / (transactionCount - 1) + } + val alpha = lerp(start = 1f, stop = LOADING_TRANSACTION_MIN_ALPHA, fraction = fraction) + TxHistoryLoadingTransaction(modifier = Modifier.alpha(alpha)) + transactionIndex++ + } + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> Unit + } + } } } @Composable -internal fun TxHistoryListItem( - state: TxHistoryUM.TxHistoryItemUM, - isBalanceHidden: Boolean, - modifier: Modifier = Modifier, -) { - when (state) { - is TxHistoryUM.TxHistoryItemUM.GroupTitle -> { - TxHistoryGroupTitle(config = state.legacyGroupTitle, modifier = modifier) - } - is TxHistoryUM.TxHistoryItemUM.Title -> { - TxHistoryTitle(onExploreClick = state.onExploreClick, modifier = modifier) - } - is TxHistoryUM.TxHistoryItemUM.Transaction -> { - Transaction( - state = state.state, - isBalanceHidden = isBalanceHidden, - modifier = modifier, +private fun TxHistoryLoadingTitle(modifier: Modifier = Modifier) { + RectangleShimmer( + modifier = modifier + .padding( + top = TangemTheme.dimens2.x6, + bottom = TangemTheme.dimens2.x3, + start = TangemTheme.dimens2.x4, ) - } + .size(width = LoadingTitleShimmerWidth, height = TangemTheme.dimens2.x4), + radius = TangemTheme.dimens2.x2, + ) +} + +@Composable +private fun TxHistoryLoadingTransaction(modifier: Modifier = Modifier) { + TangemRowContainer( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens2.x4, + vertical = TangemTheme.dimens2.x3, + ), + content = { + CircleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3) + .size(TangemTheme.dimens2.x10), + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .size(width = LoadingPrimaryShimmerWidth, height = TangemTheme.dimens2.x5), + radius = TangemTheme.dimens2.x2, + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .size(width = LoadingSecondaryShimmerWidth, height = TangemTheme.dimens2.x4), + radius = TangemTheme.dimens2.x2, + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.END_TOP) + .size(width = LoadingEndTopShimmerWidth, height = TangemTheme.dimens2.x5), + radius = TangemTheme.dimens2.x2, + ) + RectangleShimmer( + modifier = Modifier + .layoutId(TangemRowLayoutId.END_BOTTOM) + .size(width = LoadingEndBottomShimmerWidth, height = TangemTheme.dimens2.x4), + radius = TangemTheme.dimens2.x2, + ) + }, + ) +} + +@Composable +private fun TxHistoryNotSupportedBlock(state: TxHistoryUM.NotSupported, modifier: Modifier = Modifier) { + EmptyTransactionBlock( + state = EmptyTransactionsBlockState.NotImplemented( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_compass_24, + ), + modifier = modifier, + ) +} + +// region Preview +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TxHistoryLoadingBlock_Preview() { + TangemThemePreviewRedesign { + TxHistoryLoadingBlock( + state = TxHistoryUM.Loading( + isBalanceHidden = false, + onExploreClick = {}, + ), + ) } -} \ No newline at end of file +} +// endregion Preview \ No newline at end of file diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContentLegacy.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContentLegacy.kt new file mode 100644 index 0000000000..f57b597472 --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryContentLegacy.kt @@ -0,0 +1,150 @@ +package com.tangem.features.txhistory.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.R +import com.tangem.core.ui.components.list.InfiniteListHandler +import com.tangem.core.ui.components.transactions.PendingTxsBlock +import com.tangem.core.ui.components.transactions.Transaction +import com.tangem.core.ui.components.transactions.TxHistoryGroupTitle +import com.tangem.core.ui.components.transactions.TxHistoryTitle +import com.tangem.core.ui.components.transactions.empty.EmptyTransactionBlockLegacy +import com.tangem.core.ui.components.transactions.empty.EmptyTransactionsBlockState +import com.tangem.core.ui.components.transactions.state.TransactionState +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.txhistory.entity.TxHistoryUM + +private const val LOAD_ITEMS_BUFFER = 20 + +fun LazyListScope.txHistoryItemsLegacy(listState: LazyListState, state: TxHistoryUM) { + when (state) { + is TxHistoryUM.Content -> contentItems(listState, state) + is TxHistoryUM.Empty -> nonContentItem( + state = EmptyTransactionsBlockState.Empty( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + ) + is TxHistoryUM.Error -> nonContentItem( + state = EmptyTransactionsBlockState.FailedToLoad( + onReload = state.onReloadClick, + onExplore = state.onExploreClick, + reloadIconResId = R.drawable.ic_refresh_24, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + ) + is TxHistoryUM.Loading -> loadingItems(state) + is TxHistoryUM.NotSupported -> { + if (state.pendingTransactions.isNotEmpty()) { + item(key = "PendingTxsBlock", contentType = "PendingTxsBlock") { + PendingTxsBlock(pendingTxs = state.pendingTransactions, isBalanceHidden = state.isBalanceHidden) + } + } + + nonContentItem( + state = EmptyTransactionsBlockState.NotImplemented( + onExplore = state.onExploreClick, + exploreIconResId = R.drawable.ic_arrow_top_right_24, + ), + ) + } + } +} + +private fun LazyListScope.nonContentItem(state: EmptyTransactionsBlockState, modifier: Modifier = Modifier) { + item(key = state::class.java, contentType = state::class.java) { + EmptyTransactionBlockLegacy( + state = state, + modifier = modifier + .animateItem(fadeInSpec = null, fadeOutSpec = null) + .padding(horizontal = TangemTheme.dimens.spacing16, vertical = TangemTheme.dimens.spacing12) + .fillMaxWidth(), + ) + } +} + +private fun LazyListScope.loadingItems(state: TxHistoryUM.Loading) { + itemsIndexed( + items = state.items, + key = { _, item -> + when (item) { + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey + is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() + is TxHistoryUM.TxHistoryItemUM.Transaction -> + item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() + } + }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> + TxHistoryListItemLegacy( + state = item, + isBalanceHidden = true, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + ), + ) + }, + ) +} + +private fun LazyListScope.contentItems(listState: LazyListState, state: TxHistoryUM.Content) { + itemsIndexed( + items = state.items, + key = { _, item -> + when (item) { + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> item.itemKey + is TxHistoryUM.TxHistoryItemUM.Title -> item.onExploreClick.hashCode() + is TxHistoryUM.TxHistoryItemUM.Transaction -> + item.state.txHash + (item.state as? TransactionState.Content)?.hashCode() + } + }, + contentType = { _, item -> item::class.java }, + itemContent = { index, item -> + TxHistoryListItemLegacy( + state = item, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier.roundedShapeItemDecoration( + currentIndex = index, + lastIndex = state.items.lastIndex, + ), + ) + }, + ) + item { + InfiniteListHandler( + listState = listState, + buffer = LOAD_ITEMS_BUFFER, + onLoadMore = state.loadMore, + ) + } +} + +@Composable +internal fun TxHistoryListItemLegacy( + state: TxHistoryUM.TxHistoryItemUM, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + when (state) { + is TxHistoryUM.TxHistoryItemUM.GroupTitle -> { + TxHistoryGroupTitle(config = state.legacyGroupTitle, modifier = modifier) + } + is TxHistoryUM.TxHistoryItemUM.Title -> { + TxHistoryTitle(onExploreClick = state.onExploreClick, modifier = modifier) + } + is TxHistoryUM.TxHistoryItemUM.Transaction -> { + Transaction( + state = state.state, + isBalanceHidden = isBalanceHidden, + modifier = modifier, + ) + } + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt index 907f834388..7e1db0ee54 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/component/DefaultTxHistoryComponent.kt @@ -7,6 +7,7 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.txhistory.entity.TxHistoryUM import com.tangem.features.txhistory.model.TxHistoryModel import com.tangem.features.txhistory.ui.txHistoryItems +import com.tangem.features.txhistory.ui.txHistoryItemsLegacy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -22,6 +23,10 @@ internal class DefaultTxHistoryComponent @AssistedInject constructor( override val txHistoryState: StateFlow get() = model.uiState + override fun LazyListScope.txHistoryContentLegacy(listState: LazyListState, state: TxHistoryUM) { + txHistoryItemsLegacy(listState, state) + } + override fun LazyListScope.txHistoryContent(listState: LazyListState, state: TxHistoryUM) { txHistoryItems(listState, state) } From 56a2eda3e154aa8552eb6a4b7122bd13c63173f2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Apr 2026 13:16:11 +0300 Subject: [PATCH 139/206] Updated on 2026-08-14 --- core/ui/ds-tokens | 2 +- .../com/tangem/core/ui/res/TangemTheme.kt | 32 + .../tangem/core/ui/res/TangemThemeRedesign.kt | 25 +- .../tangem/core/ui/res/generated/.tokens-hash | 2 +- .../ui/res/generated/TangemColorPalette.kt | 149 ++++ .../core/ui/res/generated/TangemColors3.kt | 714 ++++++++++++++++ .../ui/res/generated/TangemColors3Dark.kt | 173 ++++ .../ui/res/generated/TangemColors3Light.kt | 173 ++++ .../ui/res/generated/TangemDarkColorTokens.kt | 118 --- .../core/ui/res/generated/TangemDimens3.kt | 108 +++ .../ui/res/generated/TangemDimensionTokens.kt | 72 -- .../res/generated/TangemLightColorTokens.kt | 118 --- .../ui/res/generated/TangemOpacityTokens.kt | 22 - .../ui/res/generated/TangemShadowTokens.kt | 15 - .../ui/res/generated/TangemTypography3.kt | 112 +++ .../res/generated/TangemTypographyTokens.kt | 88 -- core/ui/token-gen/README.md | 21 + core/ui/token-gen/build-tokens.mjs | 767 +++++++++++++----- 18 files changed, 2080 insertions(+), 631 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColorPalette.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDarkColorTokens.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimens3.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimensionTokens.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemLightColorTokens.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemOpacityTokens.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemShadowTokens.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt delete mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypographyTokens.kt create mode 100644 core/ui/token-gen/README.md diff --git a/core/ui/ds-tokens b/core/ui/ds-tokens index f4d2156236..27202508b6 160000 --- a/core/ui/ds-tokens +++ b/core/ui/ds-tokens @@ -1 +1 @@ -Subproject commit f4d2156236d7f77b61f26250351f6bfa72262a69 +Subproject commit 27202508b606f54c276afa577a2f3e7a3da27e8b diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index cb9dc458ca..f61cdd1722 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -29,6 +29,10 @@ import com.tangem.core.ui.haptic.DefaultHapticManager import com.tangem.core.ui.haptic.HapticManager import com.tangem.core.ui.haptic.VibratorHapticManager import com.tangem.core.ui.message.EventMessageHandler +import com.tangem.core.ui.res.generated.TangemColors3 +import com.tangem.core.ui.res.generated.TangemDimens3 +import com.tangem.core.ui.res.generated.TangemTypography3 +import com.tangem.core.ui.res.generated.lightColors3 import com.tangem.core.ui.windowsize.WindowSize import com.tangem.core.ui.windowsize.rememberWindowSize import com.tangem.domain.apptheme.model.AppThemeMode @@ -161,11 +165,17 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemColors.current + @Deprecated("Use colors3 instead", ReplaceWith("TangemTheme.colors3")) val colors2: TangemColors2 @Composable @ReadOnlyComposable get() = LocalTangemColors2.current + val colors3: TangemColors3 + @Composable + @ReadOnlyComposable + get() = LocalTangemColors3.current + val typography: TangemTypography @Composable @ReadOnlyComposable @@ -176,6 +186,11 @@ object TangemTheme { @ReadOnlyComposable get() = TangemTypography2(InterFamily) + val typography3: TangemTypography3 + @Composable + @ReadOnlyComposable + get() = LocalTangemTypography3.current + val dimens: TangemDimens @Composable @ReadOnlyComposable @@ -186,6 +201,11 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemDimens2.current + val dimens3: TangemDimens3 + @Composable + @ReadOnlyComposable + get() = LocalTangemDimens3.current + val shapes: TangemShapes @Composable @ReadOnlyComposable @@ -366,6 +386,10 @@ internal val LocalTangemColors2 = staticCompositionLocalOf { error("No TangemColors2 provided") } +internal val LocalTangemColors3 = staticCompositionLocalOf { + lightColors3() +} + internal val LocalTangemTypography = staticCompositionLocalOf { TangemTypography(RobotoFamily) } @@ -382,6 +406,14 @@ private val LocalTangemDimens2 = staticCompositionLocalOf { TangemDimens2() } +internal val LocalTangemDimens3 = staticCompositionLocalOf { + TangemDimens3() +} + +internal val LocalTangemTypography3 = staticCompositionLocalOf { + TangemTypography3(InterFamily) +} + private val LocalTangemShapes = staticCompositionLocalOf { error("No TangemShapes provided") } diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt index 8428d0f240..9fcfadf1f8 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemThemeRedesign.kt @@ -10,6 +10,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import com.tangem.core.ui.components.haze.ProvideHaze import com.tangem.core.ui.components.haze.hazeSourceTangem +import com.tangem.core.ui.res.generated.TangemDimens3 +import com.tangem.core.ui.res.generated.TangemTypography3 +import com.tangem.core.ui.res.generated.darkColors3 +import com.tangem.core.ui.res.generated.lightColors3 /** * Provides additional theming for redesigned components. @@ -21,17 +25,30 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) { val themeColors = if (LocalIsInDarkTheme.current) darkThemeColors() else lightThemeColors(redesign = true) val rememberedColors = remember { themeColors } .apply { update(themeColors) } + + val themeColors3 = if (LocalIsInDarkTheme.current) darkColors3() else lightColors3() + val rememberedColors3 = remember { themeColors3 } + .apply { update(themeColors3) } + val rootBackgroundColor = rememberedColors.background.secondary + val tangemDimens3 = remember { TangemDimens3() } + val tangemTypography3 = remember { TangemTypography3(InterFamily) } + val tangemTypography2 = remember { TangemTypography2(InterFamily) } + val tangemTypography = remember { TangemTypography(InterFamily) } + MaterialTheme( - colorScheme = tangemColorScheme(colors = themeColors), + colorScheme = tangemColorScheme(colors = rememberedColors), ) { CompositionLocalProvider( LocalRedesignEnabled provides true, - LocalTangemColors provides themeColors, + LocalTangemColors provides rememberedColors, LocalTangemColors2 provides if (LocalIsInDarkTheme.current) darkThemeColors2() else lightThemeColors2(), - LocalTangemTypography2 provides TangemTypography2(InterFamily), - LocalTangemTypography provides TangemTypography(InterFamily), + LocalTangemColors3 provides rememberedColors3, + LocalTangemDimens3 provides tangemDimens3, + LocalTangemTypography3 provides tangemTypography3, + LocalTangemTypography2 provides tangemTypography2, + LocalTangemTypography provides tangemTypography, LocalRootBackgroundColor provides remember(rootBackgroundColor) { mutableStateOf(rootBackgroundColor) }, ) { CompositionLocalProvider( diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash index 14287aaf78..ff5efb6203 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash @@ -1 +1 @@ -ddef7c44794c12a02c136fcc17f01fe51b6372d33a6a7eda4913d2d1b1f86203 +84387e888f54e5056380c38e077962bdfa4a32cfca194d822c13aa7e35661968 diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColorPalette.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColorPalette.kt new file mode 100644 index 0000000000..2a72bad4db --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColorPalette.kt @@ -0,0 +1,149 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +internal object TangemColorPalette { + object Base { + val black = Color(0xFF000000) + val white = Color(0xFFFFFFFF) + } + + object Neutral { + val `5` = Color(0xFFF4F4F4) + val `10` = Color(0xFFEAEAEA) + val `20` = Color(0xFFD0D0D0) + val `30` = Color(0xFFB5B5B5) + val `40` = Color(0xFF989898) + val `50` = Color(0xFF838383) + val `60` = Color(0xFF6F6F6F) + val `70` = Color(0xFF4A4A4A) + val `80` = Color(0xFF2C2C2C) + val `90` = Color(0xFF1B1B1B) + val `95` = Color(0xFF0F0F0F) + } + + object Blue { + val `5` = Color(0xFFE9F7FF) + val `10` = Color(0xFFDBF1FF) + val `20` = Color(0xFF98D7FF) + val `30` = Color(0xFF5EBDF9) + val `40` = Color(0xFF109FF0) + val `50` = Color(0xFF0090F9) + val `60` = Color(0xFF0077E1) + val `70` = Color(0xFF0C58AF) + val `80` = Color(0xFF143C70) + val `90` = Color(0xFF1B304A) + val `95` = Color(0xFF101C2C) + } + + object Violet { + val `5` = Color(0xFFF6F1FF) + val `10` = Color(0xFFEEE7FD) + val `20` = Color(0xFFDAC8FB) + val `30` = Color(0xFFC5A5FC) + val `40` = Color(0xFFB07BFD) + val `50` = Color(0xFFA967FD) + val `60` = Color(0xFF9258DC) + val `70` = Color(0xFF67419B) + val `80` = Color(0xFF473068) + val `90` = Color(0xFF332846) + val `95` = Color(0xFF201B2A) + } + + object Red { + val `5` = Color(0xFFFFF0F7) + val `10` = Color(0xFFFFE8EC) + val `20` = Color(0xFFFFC0C3) + val `30` = Color(0xFFFF979D) + val `40` = Color(0xFFFF5E66) + val `50` = Color(0xFFFE4142) + val `60` = Color(0xFFE12C2E) + val `70` = Color(0xFF9E2729) + val `80` = Color(0xFF6D2323) + val `90` = Color(0xFF4C2121) + val `95` = Color(0xFF2B1818) + } + + object Orange { + val `5` = Color(0xFFFFF0EA) + val `10` = Color(0xFFFFE5DB) + val `20` = Color(0xFFFFC3AD) + val `30` = Color(0xFFFF9976) + val `40` = Color(0xFFFA6931) + val `50` = Color(0xFFF25508) + val `60` = Color(0xFFD3480E) + val `70` = Color(0xFF953715) + val `80` = Color(0xFF652A18) + val `90` = Color(0xFF43251A) + val `95` = Color(0xFF2A1A13) + } + + object Green { + val `5` = Color(0xFFEBF6ED) + val `10` = Color(0xFFDCFBE3) + val `20` = Color(0xFF9EE1AB) + val `30` = Color(0xFF64C973) + val `40` = Color(0xFF2DAE3B) + val `50` = Color(0xFF2DA30D) + val `60` = Color(0xFF208900) + val `70` = Color(0xFF1E6110) + val `80` = Color(0xFF1C4415) + val `90` = Color(0xFF1D3319) + val `95` = Color(0xFF162114) + } + + object Yellow { + val `5` = Color(0xFFFAF3E5) + val `10` = Color(0xFFFEF5E5) + val `20` = Color(0xFFF7CA75) + val `30` = Color(0xFFF4B42F) + val `40` = Color(0xFFEFA210) + val `50` = Color(0xFFE68A03) + val `60` = Color(0xFFCD7A11) + val `70` = Color(0xFF965001) + val `80` = Color(0xFF573414) + val `90` = Color(0xFF3D2918) + val `95` = Color(0xFF241B13) + } + + object Opaque { + object BaseBlack { + val `5` = Color(0x0D000000) + val `10` = Color(0x1A000000) + val `15` = Color(0x26000000) + val `20` = Color(0x33000000) + val `25` = Color(0x40000000) + val `30` = Color(0x4D000000) + val `40` = Color(0x66000000) + val `50` = Color(0x80000000) + val `60` = Color(0x99000000) + val `80` = Color(0xCC000000) + } + + object BaseWhite { + val `5` = Color(0x0DFFFFFF) + val `10` = Color(0x1AFFFFFF) + val `15` = Color(0x26FFFFFF) + val `20` = Color(0x33FFFFFF) + val `25` = Color(0x40FFFFFF) + val `30` = Color(0x4DFFFFFF) + val `40` = Color(0x66FFFFFF) + val `50` = Color(0x80FFFFFF) + val `60` = Color(0x99FFFFFF) + val `80` = Color(0xCCFFFFFF) + } + + object Neutral95 { + val `5` = Color(0x0D0F0F0F) + val `10` = Color(0x1A0F0F0F) + val `15` = Color(0x260F0F0F) + val `40` = Color(0x660F0F0F) + val `60` = Color(0x990F0F0F) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt new file mode 100644 index 0000000000..12e2412528 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt @@ -0,0 +1,714 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +@Stable +class TangemColors3 internal constructor( + val text: Text, + val bg: Bg, + val icon: Icon, + val border: Border, + val overlay: Overlay, + val interaction: Interaction, + val material: Material, +) { + + @Stable + class Text internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + brand: Color, + val staticLight: StaticLight, + val staticDark: StaticDark, + val inverse: Inverse, + val status: Status, + val accent: Accent, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var brand by mutableStateOf(brand) + private set + + @Stable + class StaticLight internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + + fun update(other: StaticLight) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + } + } + + @Stable + class StaticDark internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + + fun update(other: StaticDark) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + } + } + + @Stable + class Inverse internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + + fun update(other: Inverse) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + } + } + + @Stable + class Status internal constructor( + success: Color, + error: Color, + warning: Color, + info: Color, + ) { + var success by mutableStateOf(success) + private set + var error by mutableStateOf(error) + private set + var warning by mutableStateOf(warning) + private set + var info by mutableStateOf(info) + private set + + fun update(other: Status) { + success = other.success + error = other.error + warning = other.warning + info = other.info + } + } + + @Stable + class Accent internal constructor( + blue: Color, + violet: Color, + red: Color, + orange: Color, + yellow: Color, + green: Color, + ) { + var blue by mutableStateOf(blue) + private set + var violet by mutableStateOf(violet) + private set + var red by mutableStateOf(red) + private set + var orange by mutableStateOf(orange) + private set + var yellow by mutableStateOf(yellow) + private set + var green by mutableStateOf(green) + private set + + fun update(other: Accent) { + blue = other.blue + violet = other.violet + red = other.red + orange = other.orange + yellow = other.yellow + green = other.green + } + } + + fun update(other: Text) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + brand = other.brand + staticLight.update(other.staticLight) + staticDark.update(other.staticDark) + inverse.update(other.inverse) + status.update(other.status) + accent.update(other.accent) + } + } + + @Stable + class Bg internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + brand: Color, + inverse: Color, + base: Color, + disabled: Color, + val opaque: Opaque, + val status: Status, + val accent: Accent, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var brand by mutableStateOf(brand) + private set + var inverse by mutableStateOf(inverse) + private set + var base by mutableStateOf(base) + private set + var disabled by mutableStateOf(disabled) + private set + + @Stable + class Opaque internal constructor( + primary: Color, + secondary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + + fun update(other: Opaque) { + primary = other.primary + secondary = other.secondary + } + } + + @Stable + class Status internal constructor( + success: Color, + successSubtle: Color, + error: Color, + errorSubtle: Color, + warning: Color, + warningSubtle: Color, + info: Color, + infoSubtle: Color, + ) { + var success by mutableStateOf(success) + private set + var successSubtle by mutableStateOf(successSubtle) + private set + var error by mutableStateOf(error) + private set + var errorSubtle by mutableStateOf(errorSubtle) + private set + var warning by mutableStateOf(warning) + private set + var warningSubtle by mutableStateOf(warningSubtle) + private set + var info by mutableStateOf(info) + private set + var infoSubtle by mutableStateOf(infoSubtle) + private set + + fun update(other: Status) { + success = other.success + successSubtle = other.successSubtle + error = other.error + errorSubtle = other.errorSubtle + warning = other.warning + warningSubtle = other.warningSubtle + info = other.info + infoSubtle = other.infoSubtle + } + } + + @Stable + class Accent internal constructor( + blue: Color, + violet: Color, + red: Color, + orange: Color, + yellow: Color, + green: Color, + ) { + var blue by mutableStateOf(blue) + private set + var violet by mutableStateOf(violet) + private set + var red by mutableStateOf(red) + private set + var orange by mutableStateOf(orange) + private set + var yellow by mutableStateOf(yellow) + private set + var green by mutableStateOf(green) + private set + + fun update(other: Accent) { + blue = other.blue + violet = other.violet + red = other.red + orange = other.orange + yellow = other.yellow + green = other.green + } + } + + fun update(other: Bg) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + brand = other.brand + inverse = other.inverse + base = other.base + disabled = other.disabled + opaque.update(other.opaque) + status.update(other.status) + accent.update(other.accent) + } + } + + @Stable + class Icon internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + brand: Color, + staticLight: Color, + staticDark: Color, + inverse: Color, + val status: Status, + val accent: Accent, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var brand by mutableStateOf(brand) + private set + var staticLight by mutableStateOf(staticLight) + private set + var staticDark by mutableStateOf(staticDark) + private set + var inverse by mutableStateOf(inverse) + private set + + @Stable + class Status internal constructor( + success: Color, + error: Color, + warning: Color, + info: Color, + ) { + var success by mutableStateOf(success) + private set + var error by mutableStateOf(error) + private set + var warning by mutableStateOf(warning) + private set + var info by mutableStateOf(info) + private set + + fun update(other: Status) { + success = other.success + error = other.error + warning = other.warning + info = other.info + } + } + + @Stable + class Accent internal constructor( + blue: Color, + violet: Color, + red: Color, + orange: Color, + yellow: Color, + green: Color, + ) { + var blue by mutableStateOf(blue) + private set + var violet by mutableStateOf(violet) + private set + var red by mutableStateOf(red) + private set + var orange by mutableStateOf(orange) + private set + var yellow by mutableStateOf(yellow) + private set + var green by mutableStateOf(green) + private set + + fun update(other: Accent) { + blue = other.blue + violet = other.violet + red = other.red + orange = other.orange + yellow = other.yellow + green = other.green + } + } + + fun update(other: Icon) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + brand = other.brand + staticLight = other.staticLight + staticDark = other.staticDark + inverse = other.inverse + status.update(other.status) + accent.update(other.accent) + } + } + + @Stable + class Border internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + brand: Color, + val inverse: Inverse, + val status: Status, + val accent: Accent, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + var brand by mutableStateOf(brand) + private set + + @Stable + class Inverse internal constructor( + primary: Color, + secondary: Color, + tertiary: Color, + ) { + var primary by mutableStateOf(primary) + private set + var secondary by mutableStateOf(secondary) + private set + var tertiary by mutableStateOf(tertiary) + private set + + fun update(other: Inverse) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + } + } + + @Stable + class Status internal constructor( + success: Color, + successSubtle: Color, + error: Color, + errorSubtle: Color, + warning: Color, + warningSubtle: Color, + info: Color, + infoSubtle: Color, + ) { + var success by mutableStateOf(success) + private set + var successSubtle by mutableStateOf(successSubtle) + private set + var error by mutableStateOf(error) + private set + var errorSubtle by mutableStateOf(errorSubtle) + private set + var warning by mutableStateOf(warning) + private set + var warningSubtle by mutableStateOf(warningSubtle) + private set + var info by mutableStateOf(info) + private set + var infoSubtle by mutableStateOf(infoSubtle) + private set + + fun update(other: Status) { + success = other.success + successSubtle = other.successSubtle + error = other.error + errorSubtle = other.errorSubtle + warning = other.warning + warningSubtle = other.warningSubtle + info = other.info + infoSubtle = other.infoSubtle + } + } + + @Stable + class Accent internal constructor( + blue: Color, + violet: Color, + red: Color, + orange: Color, + yellow: Color, + green: Color, + ) { + var blue by mutableStateOf(blue) + private set + var violet by mutableStateOf(violet) + private set + var red by mutableStateOf(red) + private set + var orange by mutableStateOf(orange) + private set + var yellow by mutableStateOf(yellow) + private set + var green by mutableStateOf(green) + private set + + fun update(other: Accent) { + blue = other.blue + violet = other.violet + red = other.red + orange = other.orange + yellow = other.yellow + green = other.green + } + } + + fun update(other: Border) { + primary = other.primary + secondary = other.secondary + tertiary = other.tertiary + brand = other.brand + inverse.update(other.inverse) + status.update(other.status) + accent.update(other.accent) + } + } + + @Stable + class Overlay internal constructor( + modal: Color, + ) { + var modal by mutableStateOf(modal) + private set + + fun update(other: Overlay) { + modal = other.modal + } + } + + @Stable + class Interaction internal constructor( + pressStaticLight: Color, + pressStaticDark: Color, + val press: Press, + val focusRing: FocusRing, + ) { + var pressStaticLight by mutableStateOf(pressStaticLight) + private set + var pressStaticDark by mutableStateOf(pressStaticDark) + private set + + @Stable + class Press internal constructor( + default: Color, + inverse: Color, + ) { + var default by mutableStateOf(default) + private set + var inverse by mutableStateOf(inverse) + private set + + fun update(other: Press) { + default = other.default + inverse = other.inverse + } + } + + @Stable + class FocusRing internal constructor( + default: Color, + brand: Color, + ) { + var default by mutableStateOf(default) + private set + var brand by mutableStateOf(brand) + private set + + fun update(other: FocusRing) { + default = other.default + brand = other.brand + } + } + + fun update(other: Interaction) { + pressStaticLight = other.pressStaticLight + pressStaticDark = other.pressStaticDark + press.update(other.press) + focusRing.update(other.focusRing) + } + } + + @Stable + class Material internal constructor( + val tint: Tint, + val fill: Fill, + val lighten: Lighten, + val softLight: SoftLight, + val border: Border, + ) { + + @Stable + class Tint internal constructor( + glass: Color, + blur: Color, + solid: Color, + ) { + var glass by mutableStateOf(glass) + private set + var blur by mutableStateOf(blur) + private set + var solid by mutableStateOf(solid) + private set + + fun update(other: Tint) { + glass = other.glass + blur = other.blur + solid = other.solid + } + } + + @Stable + class Fill internal constructor( + glass: Color, + blur: Color, + solid: Color, + ) { + var glass by mutableStateOf(glass) + private set + var blur by mutableStateOf(blur) + private set + var solid by mutableStateOf(solid) + private set + + fun update(other: Fill) { + glass = other.glass + blur = other.blur + solid = other.solid + } + } + + @Stable + class Lighten internal constructor( + glass: Color, + blur: Color, + solid: Color, + ) { + var glass by mutableStateOf(glass) + private set + var blur by mutableStateOf(blur) + private set + var solid by mutableStateOf(solid) + private set + + fun update(other: Lighten) { + glass = other.glass + blur = other.blur + solid = other.solid + } + } + + @Stable + class SoftLight internal constructor( + glass: Color, + blur: Color, + solid: Color, + ) { + var glass by mutableStateOf(glass) + private set + var blur by mutableStateOf(blur) + private set + var solid by mutableStateOf(solid) + private set + + fun update(other: SoftLight) { + glass = other.glass + blur = other.blur + solid = other.solid + } + } + + @Stable + class Border internal constructor( + start: Color, + mid: Color, + end: Color, + ) { + var start by mutableStateOf(start) + private set + var mid by mutableStateOf(mid) + private set + var end by mutableStateOf(end) + private set + + fun update(other: Border) { + start = other.start + mid = other.mid + end = other.end + } + } + + fun update(other: Material) { + tint.update(other.tint) + fill.update(other.fill) + lighten.update(other.lighten) + softLight.update(other.softLight) + border.update(other.border) + } + } + + fun update(other: TangemColors3) { + text.update(other.text) + bg.update(other.bg) + icon.update(other.icon) + border.update(other.border) + overlay.update(other.overlay) + interaction.update(other.interaction) + material.update(other.material) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt new file mode 100644 index 0000000000..a2fa0d535e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt @@ -0,0 +1,173 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + * Theme: Dark + */ +internal fun darkColors3() = + TangemColors3( + text = TangemColors3.Text( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + brand = TangemColorPalette.Blue.`50`, + staticLight = TangemColors3.Text.StaticLight( + primary = TangemColorPalette.Base.black, + secondary = TangemColorPalette.Opaque.BaseBlack.`60`, + tertiary = TangemColorPalette.Opaque.BaseBlack.`40`, + ), + staticDark = TangemColors3.Text.StaticDark( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + ), + inverse = TangemColors3.Text.Inverse( + primary = TangemColorPalette.Neutral.`95`, + secondary = TangemColorPalette.Opaque.Neutral95.`60`, + tertiary = TangemColorPalette.Opaque.Neutral95.`40`, + ), + status = TangemColors3.Text.Status( + success = TangemColorPalette.Green.`40`, + error = TangemColorPalette.Red.`40`, + warning = TangemColorPalette.Yellow.`40`, + info = TangemColorPalette.Blue.`40`, + ), + accent = TangemColors3.Text.Accent( + blue = TangemColorPalette.Blue.`40`, + violet = TangemColorPalette.Violet.`40`, + red = TangemColorPalette.Red.`40`, + orange = TangemColorPalette.Orange.`40`, + yellow = TangemColorPalette.Yellow.`40`, + green = TangemColorPalette.Green.`40`, + ), + ), + bg = TangemColors3.Bg( + primary = TangemColorPalette.Neutral.`95`, + secondary = TangemColorPalette.Neutral.`90`, + tertiary = TangemColorPalette.Neutral.`80`, + brand = TangemColorPalette.Blue.`50`, + inverse = TangemColorPalette.Neutral.`5`, + base = TangemColorPalette.Base.black, + disabled = TangemColorPalette.Neutral.`70`, + opaque = TangemColors3.Bg.Opaque( + primary = TangemColorPalette.Opaque.BaseWhite.`5`, + secondary = TangemColorPalette.Opaque.BaseWhite.`10`, + ), + status = TangemColors3.Bg.Status( + success = TangemColorPalette.Green.`50`, + successSubtle = TangemColorPalette.Green.`90`, + error = TangemColorPalette.Red.`50`, + errorSubtle = TangemColorPalette.Red.`90`, + warning = TangemColorPalette.Yellow.`50`, + warningSubtle = TangemColorPalette.Yellow.`90`, + info = TangemColorPalette.Blue.`50`, + infoSubtle = TangemColorPalette.Blue.`90`, + ), + accent = TangemColors3.Bg.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + icon = TangemColors3.Icon( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + brand = TangemColorPalette.Blue.`50`, + staticLight = TangemColorPalette.Base.black, + staticDark = TangemColorPalette.Base.white, + inverse = TangemColorPalette.Neutral.`95`, + status = TangemColors3.Icon.Status( + success = TangemColorPalette.Green.`40`, + error = TangemColorPalette.Red.`40`, + warning = TangemColorPalette.Yellow.`40`, + info = TangemColorPalette.Blue.`40`, + ), + accent = TangemColors3.Icon.Accent( + blue = TangemColorPalette.Blue.`40`, + violet = TangemColorPalette.Violet.`40`, + red = TangemColorPalette.Red.`40`, + orange = TangemColorPalette.Orange.`40`, + yellow = TangemColorPalette.Yellow.`40`, + green = TangemColorPalette.Green.`40`, + ), + ), + border = TangemColors3.Border( + primary = TangemColorPalette.Opaque.BaseWhite.`5`, + secondary = TangemColorPalette.Opaque.BaseWhite.`10`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`20`, + brand = TangemColorPalette.Blue.`50`, + inverse = TangemColors3.Border.Inverse( + primary = TangemColorPalette.Opaque.BaseBlack.`5`, + secondary = TangemColorPalette.Opaque.BaseBlack.`10`, + tertiary = TangemColorPalette.Opaque.BaseBlack.`20`, + ), + status = TangemColors3.Border.Status( + success = TangemColorPalette.Green.`40`, + successSubtle = TangemColorPalette.Green.`80`, + error = TangemColorPalette.Red.`40`, + errorSubtle = TangemColorPalette.Red.`80`, + warning = TangemColorPalette.Yellow.`40`, + warningSubtle = TangemColorPalette.Yellow.`80`, + info = TangemColorPalette.Blue.`40`, + infoSubtle = TangemColorPalette.Blue.`80`, + ), + accent = TangemColors3.Border.Accent( + blue = TangemColorPalette.Blue.`40`, + violet = TangemColorPalette.Violet.`40`, + red = TangemColorPalette.Red.`40`, + orange = TangemColorPalette.Orange.`40`, + yellow = TangemColorPalette.Yellow.`40`, + green = TangemColorPalette.Green.`40`, + ), + ), + overlay = TangemColors3.Overlay( + modal = TangemColorPalette.Opaque.BaseBlack.`80`, + ), + interaction = TangemColors3.Interaction( + pressStaticLight = TangemColorPalette.Opaque.BaseBlack.`10`, + pressStaticDark = TangemColorPalette.Opaque.BaseWhite.`10`, + press = TangemColors3.Interaction.Press( + default = TangemColorPalette.Opaque.BaseWhite.`10`, + inverse = TangemColorPalette.Opaque.BaseBlack.`10`, + ), + focusRing = TangemColors3.Interaction.FocusRing( + default = TangemColorPalette.Neutral.`5`, + brand = TangemColorPalette.Blue.`50`, + ), + ), + material = TangemColors3.Material( + tint = TangemColors3.Material.Tint( + glass = Color(0x662C2C2C), + blur = Color(0x00000000), + solid = Color(0x1AFFFFFF), + ), + fill = TangemColors3.Material.Fill( + glass = Color(0x00000000), + blur = Color(0x1AFFFFFF), + solid = Color(0xE62C2C2C), + ), + lighten = TangemColors3.Material.Lighten( + glass = Color(0x33181818), + blur = Color(0x00000000), + solid = Color(0x00000000), + ), + softLight = TangemColors3.Material.SoftLight( + glass = Color(0x1A000000), + blur = Color(0x00000000), + solid = Color(0x00000000), + ), + border = TangemColors3.Material.Border( + start = Color(0x33FFFFFF), + mid = Color(0x00FFFFFF), + end = Color(0x1AFFFFFF), + ), + ), + ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt new file mode 100644 index 0000000000..05c34c6f5e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt @@ -0,0 +1,173 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.ui.graphics.Color + +/** + * Auto-generated from design tokens. Do not edit manually. + * Theme: Light + */ +internal fun lightColors3() = + TangemColors3( + text = TangemColors3.Text( + primary = TangemColorPalette.Neutral.`95`, + secondary = TangemColorPalette.Opaque.Neutral95.`60`, + tertiary = TangemColorPalette.Opaque.Neutral95.`40`, + brand = TangemColorPalette.Blue.`50`, + staticLight = TangemColors3.Text.StaticLight( + primary = TangemColorPalette.Base.black, + secondary = TangemColorPalette.Opaque.BaseBlack.`60`, + tertiary = TangemColorPalette.Opaque.BaseBlack.`40`, + ), + staticDark = TangemColors3.Text.StaticDark( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + ), + inverse = TangemColors3.Text.Inverse( + primary = TangemColorPalette.Base.white, + secondary = TangemColorPalette.Opaque.BaseWhite.`60`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`30`, + ), + status = TangemColors3.Text.Status( + success = TangemColorPalette.Green.`50`, + error = TangemColorPalette.Red.`50`, + warning = TangemColorPalette.Yellow.`50`, + info = TangemColorPalette.Blue.`50`, + ), + accent = TangemColors3.Text.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + bg = TangemColors3.Bg( + primary = TangemColorPalette.Neutral.`5`, + secondary = TangemColorPalette.Base.white, + tertiary = TangemColorPalette.Neutral.`10`, + brand = TangemColorPalette.Blue.`50`, + inverse = TangemColorPalette.Neutral.`95`, + base = TangemColorPalette.Neutral.`5`, + disabled = TangemColorPalette.Neutral.`20`, + opaque = TangemColors3.Bg.Opaque( + primary = TangemColorPalette.Opaque.Neutral95.`5`, + secondary = TangemColorPalette.Opaque.Neutral95.`10`, + ), + status = TangemColors3.Bg.Status( + success = TangemColorPalette.Green.`50`, + successSubtle = TangemColorPalette.Green.`10`, + error = TangemColorPalette.Red.`50`, + errorSubtle = TangemColorPalette.Red.`10`, + warning = TangemColorPalette.Yellow.`50`, + warningSubtle = TangemColorPalette.Yellow.`10`, + info = TangemColorPalette.Blue.`50`, + infoSubtle = TangemColorPalette.Blue.`10`, + ), + accent = TangemColors3.Bg.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + icon = TangemColors3.Icon( + primary = TangemColorPalette.Neutral.`95`, + secondary = TangemColorPalette.Opaque.Neutral95.`60`, + tertiary = TangemColorPalette.Opaque.Neutral95.`40`, + brand = TangemColorPalette.Blue.`50`, + staticLight = TangemColorPalette.Base.black, + staticDark = TangemColorPalette.Base.white, + inverse = TangemColorPalette.Base.white, + status = TangemColors3.Icon.Status( + success = TangemColorPalette.Green.`50`, + error = TangemColorPalette.Red.`50`, + warning = TangemColorPalette.Yellow.`50`, + info = TangemColorPalette.Blue.`50`, + ), + accent = TangemColors3.Icon.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + border = TangemColors3.Border( + primary = TangemColorPalette.Opaque.BaseBlack.`5`, + secondary = TangemColorPalette.Opaque.BaseBlack.`10`, + tertiary = TangemColorPalette.Opaque.BaseBlack.`20`, + brand = TangemColorPalette.Blue.`50`, + inverse = TangemColors3.Border.Inverse( + primary = TangemColorPalette.Opaque.BaseWhite.`5`, + secondary = TangemColorPalette.Opaque.BaseWhite.`10`, + tertiary = TangemColorPalette.Opaque.BaseWhite.`20`, + ), + status = TangemColors3.Border.Status( + success = TangemColorPalette.Green.`50`, + successSubtle = TangemColorPalette.Green.`20`, + error = TangemColorPalette.Red.`50`, + errorSubtle = TangemColorPalette.Red.`20`, + warning = TangemColorPalette.Yellow.`50`, + warningSubtle = TangemColorPalette.Yellow.`20`, + info = TangemColorPalette.Blue.`50`, + infoSubtle = TangemColorPalette.Blue.`20`, + ), + accent = TangemColors3.Border.Accent( + blue = TangemColorPalette.Blue.`50`, + violet = TangemColorPalette.Violet.`50`, + red = TangemColorPalette.Red.`50`, + orange = TangemColorPalette.Orange.`50`, + yellow = TangemColorPalette.Yellow.`50`, + green = TangemColorPalette.Green.`50`, + ), + ), + overlay = TangemColors3.Overlay( + modal = TangemColorPalette.Opaque.BaseBlack.`60`, + ), + interaction = TangemColors3.Interaction( + pressStaticLight = TangemColorPalette.Opaque.BaseBlack.`10`, + pressStaticDark = TangemColorPalette.Opaque.BaseWhite.`10`, + press = TangemColors3.Interaction.Press( + default = TangemColorPalette.Opaque.BaseBlack.`10`, + inverse = TangemColorPalette.Opaque.BaseWhite.`10`, + ), + focusRing = TangemColors3.Interaction.FocusRing( + default = TangemColorPalette.Neutral.`95`, + brand = TangemColorPalette.Blue.`50`, + ), + ), + material = TangemColors3.Material( + tint = TangemColors3.Material.Tint( + glass = Color(0x00000000), + blur = Color(0x00000000), + solid = Color(0x1AFFFFFF), + ), + fill = TangemColors3.Material.Fill( + glass = Color(0x00000000), + blur = Color(0x99FFFFFF), + solid = Color(0xE6FFFFFF), + ), + lighten = TangemColors3.Material.Lighten( + glass = Color(0x80F7F7F7), + blur = Color(0x00000000), + solid = Color(0x00000000), + ), + softLight = TangemColors3.Material.SoftLight( + glass = Color(0x33000000), + blur = Color(0x00000000), + solid = Color(0x00000000), + ), + border = TangemColors3.Material.Border( + start = Color(0x26000000), + mid = Color(0x00000000), + end = Color(0x1A000000), + ), + ), + ) \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDarkColorTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDarkColorTokens.kt deleted file mode 100644 index 0f731be297..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDarkColorTokens.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.tangem.core.ui.res.generated - -import androidx.compose.ui.graphics.Color - -/** - * Auto-generated from design tokens. Do not edit manually. - * Theme: Dark - */ -internal class TangemDarkColorTokens { - // color.text - val colorTextPrimary = Color(0xFFFFFFFF) - val colorTextSecondary = Color(0x99FFFFFF) - val colorTextTertiary = Color(0x4DFFFFFF) - val colorTextBrand = Color(0xFF0090F9) - val colorTextStaticLightPrimary = Color(0xFF000000) - val colorTextStaticLightSecondary = Color(0x99000000) - val colorTextStaticLightTertiary = Color(0x66000000) - val colorTextStaticDarkPrimary = Color(0xFFFFFFFF) - val colorTextStaticDarkSecondary = Color(0x99FFFFFF) - val colorTextStaticDarkTertiary = Color(0x4DFFFFFF) - val colorTextInversePrimary = Color(0xFF0F0F0F) - val colorTextInverseSecondary = Color(0x990F0F0F) - val colorTextInverseTertiary = Color(0x660F0F0F) - val colorTextStatusSuccess = Color(0xFF2DAE3B) - val colorTextStatusError = Color(0xFFFF5E66) - val colorTextStatusWarning = Color(0xFFEFA210) - val colorTextStatusInfo = Color(0xFF109FF0) - val colorTextAccentBlue = Color(0xFF109FF0) - val colorTextAccentViolet = Color(0xFFB07BFD) - val colorTextAccentRed = Color(0xFFFF5E66) - val colorTextAccentOrange = Color(0xFFFA6931) - val colorTextAccentYellow = Color(0xFFEFA210) - val colorTextAccentGreen = Color(0xFF2DAE3B) - - // color.bg - val colorBgPrimary = Color(0xFF0F0F0F) - val colorBgSecondary = Color(0xFF1B1B1B) - val colorBgTertiary = Color(0xFF2C2C2C) - val colorBgBrand = Color(0xFF0090F9) - val colorBgInverse = Color(0xFFF4F4F4) - val colorBgBase = Color(0xFF000000) - val colorBgOpaquePrimary = Color(0x0DFFFFFF) - val colorBgOpaqueSecondary = Color(0x1AFFFFFF) - val colorBgStatusSuccess = Color(0xFF2DA30D) - val colorBgStatusError = Color(0xFFF25508) - val colorBgStatusWarning = Color(0xFFE68A03) - val colorBgStatusInfo = Color(0xFF0090F9) - val colorBgAccentBlue = Color(0xFF0090F9) - val colorBgAccentViolet = Color(0xFFA967FD) - val colorBgAccentRed = Color(0xFFFE4142) - val colorBgAccentOrange = Color(0xFFF25508) - val colorBgAccentYellow = Color(0xFFE68A03) - val colorBgAccentGreen = Color(0xFF2DA30D) - - // color.icon - val colorIconPrimary = Color(0xFFFFFFFF) - val colorIconSecondary = Color(0x99FFFFFF) - val colorIconTertiary = Color(0x4DFFFFFF) - val colorIconBrand = Color(0xFF0090F9) - val colorIconStaticLight = Color(0xFF000000) - val colorIconStaticDark = Color(0xFFFFFFFF) - val colorIconInverse = Color(0xFF0F0F0F) - val colorIconStatusSuccess = Color(0xFF2DAE3B) - val colorIconStatusError = Color(0xFFFA6931) - val colorIconStatusWarning = Color(0xFFEFA210) - val colorIconStatusInfo = Color(0xFF109FF0) - val colorIconAccentBlue = Color(0xFF109FF0) - val colorIconAccentViolet = Color(0xFFB07BFD) - val colorIconAccentRed = Color(0xFFFF5E66) - val colorIconAccentOrange = Color(0xFFFA6931) - val colorIconAccentYellow = Color(0xFFEFA210) - val colorIconAccentGreen = Color(0xFF2DAE3B) - - // color.border - val colorBorderPrimary = Color(0x0DFFFFFF) - val colorBorderSecondary = Color(0x1AFFFFFF) - val colorBorderTertiary = Color(0x33FFFFFF) - val colorBorderBrand = Color(0xFF0090F9) - val colorBorderInversePrimary = Color(0x0D000000) - val colorBorderInverseSecondary = Color(0x1A000000) - val colorBorderInverseTertiary = Color(0x33000000) - val colorBorderStatusSuccess = Color(0xFF2DAE3B) - val colorBorderStatusError = Color(0xFFFA6931) - val colorBorderStatusWarning = Color(0xFFEFA210) - val colorBorderStatusInfo = Color(0xFF109FF0) - val colorBorderAccentBlue = Color(0xFF109FF0) - val colorBorderAccentViolet = Color(0xFFB07BFD) - val colorBorderAccentRed = Color(0xFFFF5E66) - val colorBorderAccentOrange = Color(0xFFFA6931) - val colorBorderAccentYellow = Color(0xFFEFA210) - val colorBorderAccentGreen = Color(0xFF2DAE3B) - - // color.overlay - val colorOverlayModal = Color(0xCC000000) - - // color.interaction - val colorInteractionPress = Color(0x1AFFFFFF) - val colorInteractionPressStaticLight = Color(0x1A000000) - val colorInteractionPressStaticDark = Color(0x1AFFFFFF) - val colorInteractionPressInverse = Color(0x1A000000) - - // color.material - val colorMaterialTintGlass = Color(0x66181818) - val colorMaterialTintBlur = Color(0x00000000) - val colorMaterialTintSolid = Color(0x1AFFFFFF) - val colorMaterialFillGlass = Color(0x00000000) - val colorMaterialFillBlur = Color(0x1AFFFFFF) - val colorMaterialFillSolid = Color(0xE62C2C2C) - val colorMaterialLightenGlass = Color(0x33181818) - val colorMaterialLightenBlur = Color(0x00000000) - val colorMaterialLightenSolid = Color(0x00000000) - val colorMaterialSoftLightGlass = Color(0x1A000000) - val colorMaterialSoftLightBlur = Color(0x00000000) - val colorMaterialSoftLightSolid = Color(0x00000000) - val colorMaterialBorderStart = Color(0x33FFFFFF) - val colorMaterialBorderMid = Color(0x00FFFFFF) - val colorMaterialBorderEnd = Color(0x1AFFFFFF) -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimens3.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimens3.kt new file mode 100644 index 0000000000..27f3b6c69e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimens3.kt @@ -0,0 +1,108 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.runtime.Stable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +@Stable +class TangemDimens3 internal constructor( + val opacity: Opacity = Opacity(), + val blur: Blur = Blur(), + val borderRadius: BorderRadius = BorderRadius(), + val borderWidth: BorderWidth = BorderWidth(), + val size: Size = Size(), + val spacing: Spacing = Spacing(), +) { + @Stable + class Opacity internal constructor( + val disabled: Float = 0.4f, + ) + + @Stable + class Blur internal constructor( + val card: Dp = 48.dp, + val Button: Dp = 32.dp, + ) + + @Stable + class BorderRadius internal constructor( + val b100: Dp = 8.dp, + val b150: Dp = 12.dp, + val b200: Dp = 16.dp, + val b250: Dp = 20.dp, + val b300: Dp = 24.dp, + val b400: Dp = 32.dp, + val none: Dp = 0.dp, + val b075: Dp = 6.dp, + val b050: Dp = 4.dp, + val full: Dp = 999.dp, + ) + + @Stable + class BorderWidth internal constructor( + val none: Dp = 0.dp, + val xs: Dp = 0.5.dp, + val sm: Dp = 1.dp, + val md: Dp = 2.dp, + val lg: Dp = 4.dp, + ) + + @Stable + class Size internal constructor( + val s100: Dp = 8.dp, + val s125: Dp = 10.dp, + val s150: Dp = 12.dp, + val s200: Dp = 16.dp, + val s250: Dp = 20.dp, + val s300: Dp = 24.dp, + val s350: Dp = 28.dp, + val s400: Dp = 32.dp, + val s450: Dp = 36.dp, + val s500: Dp = 40.dp, + val s550: Dp = 44.dp, + val s600: Dp = 48.dp, + val s700: Dp = 56.dp, + val s800: Dp = 64.dp, + val s900: Dp = 72.dp, + val s1000: Dp = 80.dp, + val s1100: Dp = 88.dp, + val s1200: Dp = 96.dp, + val s025: Dp = 2.dp, + val s050: Dp = 4.dp, + val card: Card = Card(), + ) { + @Stable + class Card internal constructor( + val sm: Dp = 128.dp, + ) + } + + @Stable + class Spacing internal constructor( + val s100: Dp = 8.dp, + val s125: Dp = 10.dp, + val s150: Dp = 12.dp, + val s200: Dp = 16.dp, + val s250: Dp = 20.dp, + val s300: Dp = 24.dp, + val s350: Dp = 28.dp, + val s400: Dp = 32.dp, + val s450: Dp = 36.dp, + val s500: Dp = 40.dp, + val s550: Dp = 44.dp, + val s600: Dp = 48.dp, + val s700: Dp = 56.dp, + val s800: Dp = 64.dp, + val s900: Dp = 72.dp, + val s1000: Dp = 80.dp, + val s025: Dp = 2.dp, + val s050: Dp = 4.dp, + val s075: Dp = 6.dp, + val none: Dp = 0.dp, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimensionTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimensionTokens.kt deleted file mode 100644 index dcf1be90a8..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemDimensionTokens.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.tangem.core.ui.res.generated - -import androidx.compose.ui.unit.dp - -/** - * Auto-generated from design tokens. Do not edit manually. - */ -internal class TangemDimensionTokens { - // blur - val blurSizeCard = 48.dp - val blurSizeButton = 32.dp - - // border-radius - val borderRadius100 = 8.dp - val borderRadius150 = 12.dp - val borderRadius200 = 16.dp - val borderRadius250 = 20.dp - val borderRadius300 = 24.dp - val borderRadius400 = 32.dp - val borderRadiusNone = 0.dp - val borderRadius075 = 6.dp - val borderRadius050 = 4.dp - val borderRadiusFull = 999.dp - - // border-width - val borderWidthNone = 0.dp - val borderWidthXs = 0.5.dp - val borderWidthSm = 1.dp - val borderWidthMd = 2.dp - val borderWidthLg = 4.dp - - // size - val size100 = 8.dp - val size125 = 10.dp - val size150 = 12.dp - val size200 = 16.dp - val size250 = 20.dp - val size300 = 24.dp - val size350 = 28.dp - val size400 = 32.dp - val size450 = 36.dp - val size500 = 40.dp - val size550 = 44.dp - val size600 = 48.dp - val size700 = 56.dp - val size800 = 64.dp - val size1000 = 80.dp - val size1100 = 88.dp - val size1200 = 96.dp - val size025 = 2.dp - val size050 = 4.dp - val sizeCardSm = 128.dp - - // spacing - val spacing100 = 8.dp - val spacing150 = 12.dp - val spacing200 = 16.dp - val spacing250 = 20.dp - val spacing300 = 24.dp - val spacing350 = 28.dp - val spacing400 = 32.dp - val spacing450 = 36.dp - val spacing500 = 40.dp - val spacing550 = 44.dp - val spacing600 = 48.dp - val spacing700 = 56.dp - val spacing800 = 64.dp - val spacing1000 = 80.dp - val spacing025 = 2.dp - val spacing050 = 4.dp - val spacingNone = 0.dp -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemLightColorTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemLightColorTokens.kt deleted file mode 100644 index 75c0758b25..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemLightColorTokens.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.tangem.core.ui.res.generated - -import androidx.compose.ui.graphics.Color - -/** - * Auto-generated from design tokens. Do not edit manually. - * Theme: Light - */ -internal class TangemLightColorTokens { - // color.text - val colorTextPrimary = Color(0xFF0F0F0F) - val colorTextSecondary = Color(0x990F0F0F) - val colorTextTertiary = Color(0x660F0F0F) - val colorTextBrand = Color(0xFF0090F9) - val colorTextStaticLightPrimary = Color(0xFF000000) - val colorTextStaticLightSecondary = Color(0x99000000) - val colorTextStaticLightTertiary = Color(0x66000000) - val colorTextStaticDarkPrimary = Color(0xFFFFFFFF) - val colorTextStaticDarkSecondary = Color(0x99FFFFFF) - val colorTextStaticDarkTertiary = Color(0x4DFFFFFF) - val colorTextInversePrimary = Color(0xFFFFFFFF) - val colorTextInverseSecondary = Color(0x99FFFFFF) - val colorTextInverseTertiary = Color(0x4DFFFFFF) - val colorTextStatusSuccess = Color(0xFF2DA30D) - val colorTextStatusError = Color(0xFFFE4142) - val colorTextStatusWarning = Color(0xFFE68A03) - val colorTextStatusInfo = Color(0xFF0090F9) - val colorTextAccentBlue = Color(0xFF0090F9) - val colorTextAccentViolet = Color(0xFFA967FD) - val colorTextAccentRed = Color(0xFFFE4142) - val colorTextAccentOrange = Color(0xFFF25508) - val colorTextAccentYellow = Color(0xFFE68A03) - val colorTextAccentGreen = Color(0xFF2DA30D) - - // color.bg - val colorBgPrimary = Color(0xFFF4F4F4) - val colorBgSecondary = Color(0xFFFFFFFF) - val colorBgTertiary = Color(0xFFEAEAEA) - val colorBgBrand = Color(0xFF0090F9) - val colorBgInverse = Color(0xFFF4F4F4) - val colorBgBase = Color(0xFFF4F4F4) - val colorBgOpaquePrimary = Color(0x0DFFFFFF) - val colorBgOpaqueSecondary = Color(0x1AFFFFFF) - val colorBgStatusSuccess = Color(0xFF2DA30D) - val colorBgStatusError = Color(0xFFF25508) - val colorBgStatusWarning = Color(0xFFE68A03) - val colorBgStatusInfo = Color(0xFF0090F9) - val colorBgAccentBlue = Color(0xFF0090F9) - val colorBgAccentViolet = Color(0xFFA967FD) - val colorBgAccentRed = Color(0xFFFE4142) - val colorBgAccentOrange = Color(0xFFF25508) - val colorBgAccentYellow = Color(0xFFE68A03) - val colorBgAccentGreen = Color(0xFF2DA30D) - - // color.icon - val colorIconPrimary = Color(0xFF0F0F0F) - val colorIconSecondary = Color(0x990F0F0F) - val colorIconTertiary = Color(0x660F0F0F) - val colorIconBrand = Color(0xFF0090F9) - val colorIconStaticLight = Color(0xFF000000) - val colorIconStaticDark = Color(0xFFFFFFFF) - val colorIconInverse = Color(0xFFFFFFFF) - val colorIconStatusSuccess = Color(0xFF2DA30D) - val colorIconStatusError = Color(0xFFF25508) - val colorIconStatusWarning = Color(0xFFE68A03) - val colorIconStatusInfo = Color(0xFF0090F9) - val colorIconAccentBlue = Color(0xFF0090F9) - val colorIconAccentViolet = Color(0xFFA967FD) - val colorIconAccentRed = Color(0xFFFE4142) - val colorIconAccentOrange = Color(0xFFF25508) - val colorIconAccentYellow = Color(0xFFE68A03) - val colorIconAccentGreen = Color(0xFF2DA30D) - - // color.border - val colorBorderPrimary = Color(0x0D000000) - val colorBorderSecondary = Color(0x1A000000) - val colorBorderTertiary = Color(0x33000000) - val colorBorderBrand = Color(0xFF0090F9) - val colorBorderInversePrimary = Color(0x0D000000) - val colorBorderInverseSecondary = Color(0x1A000000) - val colorBorderInverseTertiary = Color(0x33000000) - val colorBorderStatusSuccess = Color(0xFF2DA30D) - val colorBorderStatusError = Color(0xFFF25508) - val colorBorderStatusWarning = Color(0xFFE68A03) - val colorBorderStatusInfo = Color(0xFF0090F9) - val colorBorderAccentBlue = Color(0xFF0090F9) - val colorBorderAccentViolet = Color(0xFFA967FD) - val colorBorderAccentRed = Color(0xFFFE4142) - val colorBorderAccentOrange = Color(0xFFF25508) - val colorBorderAccentYellow = Color(0xFFE68A03) - val colorBorderAccentGreen = Color(0xFF2DA30D) - - // color.overlay - val colorOverlayModal = Color(0x99000000) - - // color.interaction - val colorInteractionPress = Color(0x1A000000) - val colorInteractionPressStaticLight = Color(0x1A000000) - val colorInteractionPressStaticDark = Color(0x1AFFFFFF) - val colorInteractionPressInverse = Color(0x1AFFFFFF) - - // color.material - val colorMaterialTintGlass = Color(0x00000000) - val colorMaterialTintBlur = Color(0x00000000) - val colorMaterialTintSolid = Color(0x1AFFFFFF) - val colorMaterialFillGlass = Color(0x00000000) - val colorMaterialFillBlur = Color(0x99FFFFFF) - val colorMaterialFillSolid = Color(0xE6FFFFFF) - val colorMaterialLightenGlass = Color(0x80F7F7F7) - val colorMaterialLightenBlur = Color(0x00000000) - val colorMaterialLightenSolid = Color(0x00000000) - val colorMaterialSoftLightGlass = Color(0x33000000) - val colorMaterialSoftLightBlur = Color(0x00000000) - val colorMaterialSoftLightSolid = Color(0x00000000) - val colorMaterialBorderStart = Color(0x26000000) - val colorMaterialBorderMid = Color(0x00000000) - val colorMaterialBorderEnd = Color(0x1A000000) -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemOpacityTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemOpacityTokens.kt deleted file mode 100644 index 0de7875fdb..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemOpacityTokens.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.core.ui.res.generated - -/** - * Auto-generated from design tokens. Do not edit manually. - */ -internal class TangemOpacityTokens { - val opacity0 = 0f - val opacity5 = 0.05f - val opacity10 = 0.1f - val opacity15 = 0.15f - val opacity20 = 0.2f - val opacity25 = 0.25f - val opacity30 = 0.3f - val opacity40 = 0.4f - val opacity50 = 0.5f - val opacity60 = 0.6f - val opacity70 = 0.7f - val opacity80 = 0.8f - val opacity90 = 0.9f - val opacity100 = 1f - val opacityDisabled = 0.4f -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemShadowTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemShadowTokens.kt deleted file mode 100644 index 5677a413a4..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemShadowTokens.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.core.ui.res.generated - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp - -/** - * Auto-generated from design tokens. Do not edit manually. - */ -internal class TangemShadowTokens { - val shadowButtonBlur = 40.dp - val shadowButtonOffsetX = 0.dp - val shadowButtonOffsetY = 8.dp - val shadowButtonSpread = 0.dp - val shadowButtonColor = Color(0x1A000000) -} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt new file mode 100644 index 0000000000..10e6eb6ccb --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt @@ -0,0 +1,112 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated + +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.sp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ +@Stable +class TangemTypography3 internal constructor(fontFamily: FontFamily) { + val display: Display = Display(fontFamily) + val heading: Heading = Heading(fontFamily) + val body: Body = Body(fontFamily) + val subheading: Subheading = Subheading(fontFamily) + val caption: Caption = Caption(fontFamily) + + @Stable + class Display internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 44.sp, + lineHeight = 52.sp, + letterSpacing = (-0.92).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + lineBreak = LineBreak.Heading, + ) + } + + @Stable + class Heading internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 33.sp, + letterSpacing = (-0.37).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + lineBreak = LineBreak.Heading, + ) + val small: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 24.sp, + letterSpacing = (-0.12).sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + lineBreak = LineBreak.Heading, + ) + } + + @Stable + class Body internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 20.sp, + letterSpacing = 0.02.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + } + + @Stable + class Subheading internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 17.sp, + letterSpacing = 0.07.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + } + + @Stable + class Caption internal constructor(fontFamily: FontFamily) { + val medium: TextStyle = TextStyle( + fontFamily = fontFamily, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.18.sp, + lineHeightStyle = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.None, + ), + ) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypographyTokens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypographyTokens.kt deleted file mode 100644 index d3fcd82486..0000000000 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypographyTokens.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.tangem.core.ui.res.generated - -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.LineBreak -import androidx.compose.ui.text.style.LineHeightStyle -import androidx.compose.ui.unit.sp -import com.tangem.core.ui.res.InterFamily - -/** - * Auto-generated from design tokens. Do not edit manually. - */ -internal class TangemTypographyTokens { - val fontDisplayMedium = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 44.sp, - lineHeight = 52.sp, - letterSpacing = (-0.92).sp, - lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Center, - trim = LineHeightStyle.Trim.None, - ), - lineBreak = LineBreak.Heading, - ) - - val fontHeadingMedium = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 28.sp, - lineHeight = 33.sp, - letterSpacing = (-0.37).sp, - lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Center, - trim = LineHeightStyle.Trim.None, - ), - lineBreak = LineBreak.Heading, - ) - - val fontHeadingSmall = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.SemiBold, - fontSize = 20.sp, - lineHeight = 24.sp, - letterSpacing = (-0.12).sp, - lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Center, - trim = LineHeightStyle.Trim.None, - ), - lineBreak = LineBreak.Heading, - ) - - val fontBodyMedium = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Medium, - fontSize = 16.sp, - lineHeight = 20.sp, - letterSpacing = 0.02.sp, - lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Center, - trim = LineHeightStyle.Trim.None, - ), - ) - - val fontSubheadingMedium = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - lineHeight = 17.sp, - letterSpacing = 0.07.sp, - lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Center, - trim = LineHeightStyle.Trim.None, - ), - ) - - val fontCaptionMedium = TextStyle( - fontFamily = InterFamily, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - lineHeight = 16.sp, - letterSpacing = 0.18.sp, - lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Center, - trim = LineHeightStyle.Trim.None, - ), - ) -} \ No newline at end of file diff --git a/core/ui/token-gen/README.md b/core/ui/token-gen/README.md new file mode 100644 index 0000000000..62736c8beb --- /dev/null +++ b/core/ui/token-gen/README.md @@ -0,0 +1,21 @@ +# token-gen + +Generates Kotlin (Jetpack Compose) source files from design tokens defined in the `ds-tokens` git submodule. + +## Updating tokens + +1. Update the `ds-tokens` submodule to the latest commit: + ```bash + git submodule update --remote core/ui/ds-tokens + ``` +2. Re-run the build: + ```bash + cd core/ui/token-gen && npm run build + ``` +3. Commit both the submodule pointer and generated files. + +## How it works + +The script uses [Style Dictionary v5](https://styledictionary.com/) with [@tokens-studio/sd-transforms](https://github.com/tokens-studio/sd-transforms) to read JSON token files from `core/ui/ds-tokens/tokens/` and generate Kotlin files into `core/ui/src/main/java/com/tangem/core/ui/res/generated/`. + +All generated files are written to `com.tangem.core.ui.res.generated` and should not be edited manually. diff --git a/core/ui/token-gen/build-tokens.mjs b/core/ui/token-gen/build-tokens.mjs index 0add5d002f..9d30c49158 100644 --- a/core/ui/token-gen/build-tokens.mjs +++ b/core/ui/token-gen/build-tokens.mjs @@ -149,47 +149,368 @@ function groupByPath(tokens, depth = 1) { return groups; } +/** + * Build a tree of nested objects from entries. + * Each entry: { path: string[], value: string }. + * The last segment is the property name; preceding segments become nested objects. + */ +function buildPropertyTree(entries) { + const root = { props: [], children: new Map() }; + + for (const { path, value } of entries) { + let node = root; + for (let i = 0; i < path.length - 1; i++) { + const seg = path[i]; + if (!node.children.has(seg)) { + node.children.set(seg, { props: [], children: new Map() }); + } + node = node.children.get(seg); + } + const propName = path[path.length - 1]; + const existingIdx = node.props.findIndex(p => p.name === propName); + if (existingIdx >= 0) { + console.warn(` ⚠ Duplicate property path: ${path.join('.')} — overwriting`); + node.props[existingIdx] = { name: propName, value }; + } else { + node.props.push({ name: propName, value }); + } + } + + return root; +} + +/** + * Render a property tree as Kotlin nested objects. + * Returns an array of indented lines. + */ +function renderTree(node, indent = 1) { + const pad = ' '.repeat(indent); + const lines = []; + + for (const { name, value } of node.props) { + lines.push(`${pad}val ${name} = ${value}`); + } + + for (const [name, child] of node.children) { + if (lines.length > 0) lines.push(''); + lines.push(`${pad}object ${name} {`); + lines.push(...renderTree(child, indent + 1)); + lines.push(`${pad}}`); + } + + return lines; +} + +/** + * Render a @Stable class tree for dimension tokens. + * Each node with children becomes a nested @Stable class. + * Props carry { default, type } values. + */ +function renderStableDimenClass(className, node, indent) { + const pad = ' '.repeat(indent); + const pad1 = ' '.repeat(indent + 1); + const lines = []; + + lines.push(`${pad}@Stable`); + lines.push(`${pad}class ${className} internal constructor(`); + + for (const { name, value } of node.props) { + lines.push(`${pad1}val ${kotlinSafe(name)}: ${value.type} = ${value.default},`); + } + for (const [childName, childNode] of node.children) { + const typeName = capitalize(childName); + const propName = kotlinSafe(childName.charAt(0).toLowerCase() + childName.slice(1)); + lines.push(`${pad1}val ${propName}: ${typeName} = ${typeName}(),`); + } + + if (node.children.size === 0) { + lines.push(`${pad})`); + } else { + lines.push(`${pad}) {`); + + let first = true; + for (const [childName, childNode] of node.children) { + if (!first) lines.push(''); + first = false; + lines.push(...renderStableDimenClass(capitalize(childName), childNode, indent + 1)); + } + + lines.push(`${pad}}`); + } + + return lines; +} + +/** + * Capitalize the first letter of a string. + */ +function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Convert a kebab-case segment to PascalCase (for object names). + */ +function toPascalCase(seg) { + return seg + .split('-') + .map(part => capitalize(part)) + .join(''); +} + +/** + * Wrap a name in backticks if it starts with a digit or is a Kotlin hard keyword. + */ +const KOTLIN_HARD_KEYWORDS = new Set([ + 'as', 'break', 'class', 'continue', 'do', 'else', 'false', 'for', 'fun', + 'if', 'in', 'interface', 'is', 'null', 'object', 'package', 'return', + 'super', 'this', 'throw', 'true', 'try', 'typealias', 'typeof', 'val', + 'var', 'when', 'while', +]); + +function kotlinSafe(name) { + if (/^\d/.test(name) || KOTLIN_HARD_KEYWORDS.has(name)) return `\`${name}\``; + return name; +} + +// ── TangemColors3 helpers ───────────────────────────────────────────────────── + +/** Shared structure tree for TangemColors3, computed from light theme codeSyntax. */ +let colors3StructureTree = null; + +/** Extract codeSyntax.Android path, stripping TangemTheme.colors3. prefix. */ +function getAndroidCodeSyntax(token) { + const ext = token.$extensions?.['com.figma.codeSyntax']; + if (!ext?.Android) return null; + const prefix = 'TangemTheme.colors3.'; + const android = ext.Android; + return android.startsWith(prefix) ? android.slice(prefix.length) : null; +} + +/** Get the token's property path for the class tree from codeSyntax, or fallback to JSON path. */ +function colorTokenClassPath(token) { + const cs = getAndroidCodeSyntax(token); + if (cs) return cs.split('.'); + // Fallback for material tokens (no codeSyntax): use JSON path minus 'color' prefix + const pathSegs = token.path.slice(1); // remove 'color' + return pathSegs.map(seg => seg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())); +} + +/** + * Extract a simple palette reference from a token's original (unresolved) value. + * Returns the reference string like "palette.neutral.95" or null for complex values. + */ +function extractPaletteRef(token) { + const original = token.original?.$value; + if (typeof original !== 'string') return null; + const match = original.match(/^\{(palette\.[^}]+)\}$/); + return match ? match[1] : null; +} + +/** + * Convert a palette reference path to Kotlin code referencing TangemColorPalette. + * e.g., "palette.neutral.95" → "TangemColorPalette.Neutral.`95`" + * e.g., "palette.opaque.base-black.60" → "TangemColorPalette.Opaque.BaseBlack.`60`" + */ +function paletteRefToKotlin(refPath) { + const parts = refPath.split('.').slice(1); // drop "palette" + const objParts = parts.slice(0, -1).map(seg => toPascalCase(seg)); + const leaf = parts[parts.length - 1].replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); + return `TangemColorPalette.${objParts.join('.')}.${kotlinSafe(leaf)}`; +} + +/** Get Kotlin value for a color token: palette reference if simple, else resolved Color literal. */ +function paletteRefOrColor(token) { + const ref = extractPaletteRef(token); + if (ref) return paletteRefToKotlin(ref); + return toComposeColor(token.$value, token.path.join('.')); +} + +/** + * Build a class structure tree from color tokens using codeSyntax paths. + * Each node: { props: [{name, jsonPath}], children: Map } + */ +function buildClassStructureTree(tokens) { + const root = { props: [], children: new Map() }; + + for (const token of tokens) { + const classPath = colorTokenClassPath(token); + const jsonPath = token.path.join('.'); + + let node = root; + for (let i = 0; i < classPath.length - 1; i++) { + const seg = classPath[i]; + if (!node.children.has(seg)) { + node.children.set(seg, { props: [], children: new Map() }); + } + node = node.children.get(seg); + } + + const propName = classPath[classPath.length - 1]; + const existingIdx = node.props.findIndex(p => p.name === propName); + if (existingIdx >= 0) { + console.warn(` ⚠ Duplicate codeSyntax path: ${classPath.join('.')} (${jsonPath}) — overwriting`); + node.props[existingIdx] = { name: propName, jsonPath }; + } else { + node.props.push({ name: propName, jsonPath }); + } + } + + return root; +} + +/** + * Resolve conflicts where a name appears as both a leaf property and a child class. + * Resolution: move the leaf into the child as "default". + */ +function resolveClassTreeConflicts(node) { + const propsToRemove = []; + + for (const [childName, childNode] of node.children) { + const conflictIdx = node.props.findIndex(p => p.name === childName); + if (conflictIdx >= 0) { + const prop = node.props[conflictIdx]; + console.log(` ℹ Conflict resolved: "${childName}" is both property and class → moved to "${childName}.default"`); + childNode.props.unshift({ name: 'default', jsonPath: prop.jsonPath }); + propsToRemove.push(conflictIdx); + } + } + + for (const idx of propsToRemove.sort((a, b) => b - a)) { + node.props.splice(idx, 1); + } + + for (const child of node.children.values()) { + resolveClassTreeConflicts(child); + } +} + +/** + * Render a @Stable class with mutableStateOf pattern for Compose theme colors. + * Returns array of Kotlin source lines. + */ +function renderStableClass(className, node, indent = 0) { + const pad = ' '.repeat(indent); + const pad1 = ' '.repeat(indent + 1); + const pad2 = ' '.repeat(indent + 2); + const lines = []; + + lines.push(`${pad}@Stable`); + lines.push(`${pad}class ${className} internal constructor(`); + + for (const { name } of node.props) { + lines.push(`${pad1}${kotlinSafe(name)}: Color,`); + } + for (const [childName] of node.children) { + lines.push(`${pad1}val ${childName}: ${capitalize(childName)},`); + } + + lines.push(`${pad}) {`); + + // mutableStateOf delegates for leaf Color props + if (node.props.length > 0) { + for (const { name } of node.props) { + const safe = kotlinSafe(name); + lines.push(`${pad1}var ${safe} by mutableStateOf(${safe})`); + lines.push(`${pad2}private set`); + } + } + + // Nested child classes + for (const [childName, childNode] of node.children) { + lines.push(''); + lines.push(...renderStableClass(capitalize(childName), childNode, indent + 1)); + } + + // update() function + lines.push(''); + lines.push(`${pad1}fun update(other: ${className}) {`); + for (const { name } of node.props) { + const safe = kotlinSafe(name); + lines.push(`${pad2}${safe} = other.${safe}`); + } + for (const [childName] of node.children) { + lines.push(`${pad2}${childName}.update(other.${childName})`); + } + lines.push(`${pad1}}`); + + lines.push(`${pad}}`); + + return lines; +} + +/** + * Render the content lines of a factory constructor call (param assignments + child constructors). + */ +function renderFactoryContent(classPath, node, valueMap, indent) { + const pad = ' '.repeat(indent); + const lines = []; + + for (const { name, jsonPath } of node.props) { + const value = valueMap.get(jsonPath); + if (!value) console.warn(` ⚠ No value for jsonPath "${jsonPath}" (property: ${name})`); + lines.push(`${pad}${kotlinSafe(name)} = ${value || 'Color.Unspecified'},`); + } + + for (const [childName, childNode] of node.children) { + const childClassPath = `${classPath}.${capitalize(childName)}`; + lines.push(`${pad}${childName} = ${childClassPath}(`); + lines.push(...renderFactoryContent(childClassPath, childNode, valueMap, indent + 1)); + lines.push(`${pad}),`); + } + + return lines; +} + +const FILE_SUPPRESS = '@file:Suppress("all")'; + // ── Custom formats ───────────────────────────────────────────────────────────── /** - * Kotlin format for color tokens. - * Generates: class TangemLightColorTokens / TangemDarkColorTokens + * Kotlin format for palette tokens. + * Generates nested objects: object Base { val black = ... }, object Neutral { val `5` = ... }, etc. */ StyleDictionary.registerFormat({ - name: 'kotlin/compose-colors', - format: ({ dictionary, options }) => { - const themeName = options.themeName; // "Light" or "Dark" - const objectName = `Tangem${themeName}ColorTokens`; - - const colorTokens = dictionary.allTokens.filter( - t => t.$type === 'color' && !isSourceOnlyToken(t), + name: 'kotlin/compose-palette', + format: ({ dictionary }) => { + const paletteTokens = dictionary.allTokens.filter( + t => t.$type === 'color' && t.path[0] === 'palette', ); - // Group by top-level category (color.text, color.bg, color.icon, etc.) - const groups = groupByPath(colorTokens, 2); - const sections = []; - - for (const [groupKey, tokens] of Object.entries(groups)) { - const comment = ` // ${groupKey}`; - const props = tokens.map(token => { - const propName = toCamelCase(token.path); - const value = toComposeColor(token.$value, token.path.join('.')); - return ` val ${propName} = ${value}`; + const entries = paletteTokens.map(token => { + // palette.base.black → ["Base", "black"] + // palette.neutral.5 → ["Neutral", "`5`"] + // palette.opaque.base-black.5 → ["Opaque", "BaseBlack", "`5`"] + const segments = token.path.slice(1); // drop "palette" + const path = segments.map((seg, i) => { + if (i < segments.length - 1) { + // intermediate segments → PascalCase object names + return toPascalCase(seg); + } + // leaf segment → property name, backtick-wrap if starts with digit + const camel = seg.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); + return kotlinSafe(camel); }); - sections.push([comment, ...props].join('\n')); - } + + const value = toComposeColor(token.$value, token.path.join('.')); + return { path, value }; + }); + + const tree = buildPropertyTree(entries); + const body = renderTree(tree); return [ + FILE_SUPPRESS, + '', `package ${PACKAGE}`, '', 'import androidx.compose.ui.graphics.Color', '', '/**', - ` * Auto-generated from design tokens. Do not edit manually.`, - ` * Theme: ${themeName}`, + ' * Auto-generated from design tokens. Do not edit manually.', ' */', - `internal class ${objectName} {`, - sections.join('\n\n'), + 'internal object TangemColorPalette {', + body.join('\n'), '}', '', ].join('\n'); @@ -197,148 +518,150 @@ StyleDictionary.registerFormat({ }); /** - * Kotlin format for dimension tokens (spacing, size, border-radius, border-width). + * Kotlin format for TangemDimens3 — structured dimension tokens as @Immutable data class. + * Generates nested @Immutable data classes from codeSyntax.Android paths (prefix: TangemTheme.dimens3.). + * Includes spacing, size, borderRadius, borderWidth, blur, and semantic opacity tokens. */ StyleDictionary.registerFormat({ - name: 'kotlin/compose-dimensions', + name: 'kotlin/compose-dimens3', format: ({ dictionary }) => { - const dimTokens = dictionary.allTokens.filter(t => { - // Exclude font tokens — letter-spacing values are already in typography tokens as sp - if (t.path[0] === 'font') return false; - return ( - (t.$type === 'dimension' || t.$type === 'borderRadius' || t.$type === 'borderWidth') && - !isSourceOnlyToken(t) - ); - }); + const prefix = 'TangemTheme.dimens3.'; - const groups = groupByPath(dimTokens, 1); - const sections = []; + const entries = []; + for (const token of dictionary.allTokens) { + const ext = token.$extensions?.['com.figma.codeSyntax']; + if (!ext?.Android?.startsWith(prefix)) continue; - for (const [groupKey, tokens] of Object.entries(groups)) { - const comment = ` // ${groupKey}`; - const props = tokens.map(token => { - const propName = toCamelCase(token.path); - // Resolved value is in px (number), convert to dp - const raw = parseFloat(token.$value); - if (isNaN(raw)) throw new Error(`Non-numeric dimension value for ${token.path.join('.')}: "${token.$value}"`); - const dpVal = `${raw}.dp`; - return ` val ${propName} = ${dpVal}`; - }); - sections.push([comment, ...props].join('\n')); + const codePath = ext.Android.slice(prefix.length); + const segPath = codePath.split('.'); + + const raw = parseFloat(token.$value); + const tp = token.path.join('.'); + if (isNaN(raw)) throw new Error(`Non-numeric value for ${tp}: "${token.$value}"`); + + // Opacity tokens → Float, all others → Dp + const isOpacity = token.$type === 'opacity'; + const value = isOpacity ? `${raw}f` : `${raw}.dp`; + const type = isOpacity ? 'Float' : 'Dp'; + + entries.push({ path: segPath, value, type }); } + const tree = buildPropertyTree(entries.map(e => ({ + path: e.path, + value: { default: e.value, type: e.type }, + }))); + + const classLines = renderStableDimenClass('TangemDimens3', tree, 0); + return [ + FILE_SUPPRESS, + '', `package ${PACKAGE}`, '', + 'import androidx.compose.runtime.Stable', + 'import androidx.compose.ui.unit.Dp', 'import androidx.compose.ui.unit.dp', '', '/**', ' * Auto-generated from design tokens. Do not edit manually.', ' */', - 'internal class TangemDimensionTokens {', - sections.join('\n\n'), - '}', + ...classLines, '', ].join('\n'); }, }); /** - * Kotlin format for opacity tokens. + * Kotlin format for TangemTypography3 — @Stable class with nested categories. + * Generates a class taking FontFamily, with nested classes for each typography category + * (display, heading, body, subheading, caption). */ StyleDictionary.registerFormat({ - name: 'kotlin/compose-opacity', - format: ({ dictionary }) => { - const opacityTokens = dictionary.allTokens.filter( - t => t.$type === 'opacity' && !isSourceOnlyToken(t), - ); - - const props = opacityTokens.map(token => { - const propName = toCamelCase(token.path); - const raw = parseFloat(token.$value); - if (isNaN(raw)) throw new Error(`Non-numeric opacity value for ${token.path.join('.')}: "${token.$value}"`); - const floatVal = `${raw}f`; - return ` val ${propName} = ${floatVal}`; - }); - - return [ - `package ${PACKAGE}`, - '', - '/**', - ' * Auto-generated from design tokens. Do not edit manually.', - ' */', - 'internal class TangemOpacityTokens {', - ...props, - '}', - '', - ].join('\n'); - }, -}); - -/** - * Kotlin format for typography tokens. - */ -StyleDictionary.registerFormat({ - name: 'kotlin/compose-typography', + name: 'kotlin/compose-typography3', format: ({ dictionary }) => { const typoTokens = dictionary.allTokens.filter( t => t.$type === 'typography' && !isSourceOnlyToken(t), ); - const props = typoTokens.map(token => { - const propName = toCamelCase(token.path); - const v = token.$value; + // Group by category (path[1]: display, heading, body, subheading, caption) + const categories = new Map(); + for (const token of typoTokens) { + const category = token.path[1]; + if (!categories.has(category)) categories.set(category, []); + categories.get(category).push(token); + } - // v is an object: { fontFamily, fontWeight, fontSize, lineHeight, letterSpacing, ... } - const tp = token.path.join('.'); - if (!v.fontWeight) throw new Error(`Missing fontWeight for ${tp}`); - const fontWeight = mapFontWeight(v.fontWeight); - const fontSize = parseFloat(v.fontSize); - if (isNaN(fontSize)) throw new Error(`Non-numeric fontSize for ${tp}: "${v.fontSize}"`); - const lineHeight = parseFloat(v.lineHeight); - if (isNaN(lineHeight)) throw new Error(`Non-numeric lineHeight for ${tp}: "${v.lineHeight}"`); - const letterSpacing = parseFloat(v.letterSpacing); - if (isNaN(letterSpacing)) throw new Error(`Non-numeric letterSpacing for ${tp}: "${v.letterSpacing}"`); + // Build nested class lines + const outerProps = []; + const innerClasses = []; - // display and heading categories get LineBreak.Heading (matches TangemTypography2) - const category = token.path[1]; // display, heading, body, subheading, caption + for (const [category, tokens] of categories) { + const className = capitalize(category); + const propName = category; const isHeading = category === 'display' || category === 'heading'; - const lines = [ - ` val ${propName} = TextStyle(`, - ` fontFamily = InterFamily,`, - ` fontWeight = ${fontWeight},`, - ` fontSize = ${fontSize}.sp,`, - ` lineHeight = ${lineHeight}.sp,`, - ` letterSpacing = ${letterSpacing < 0 ? `(${letterSpacing})` : letterSpacing}.sp,`, - ` lineHeightStyle = LineHeightStyle(`, - ` alignment = LineHeightStyle.Alignment.Center,`, - ` trim = LineHeightStyle.Trim.None,`, - ` ),`, - ]; - if (isHeading) { - lines.push(` lineBreak = LineBreak.Heading,`); - } - lines.push(` )`); + outerProps.push(` val ${propName}: ${className} = ${className}(fontFamily)`); - return lines.join('\n'); - }); + const classLines = [` @Stable`, ` class ${className} internal constructor(fontFamily: FontFamily) {`]; + + for (const token of tokens) { + const size = token.path[2]; // medium, small, etc. + const v = token.$value; + const tp = token.path.join('.'); + + if (!v.fontWeight) throw new Error(`Missing fontWeight for ${tp}`); + const fontWeight = mapFontWeight(v.fontWeight); + const fontSize = parseFloat(v.fontSize); + if (isNaN(fontSize)) throw new Error(`Non-numeric fontSize for ${tp}: "${v.fontSize}"`); + const lineHeight = parseFloat(v.lineHeight); + if (isNaN(lineHeight)) throw new Error(`Non-numeric lineHeight for ${tp}: "${v.lineHeight}"`); + const letterSpacing = parseFloat(v.letterSpacing); + if (isNaN(letterSpacing)) throw new Error(`Non-numeric letterSpacing for ${tp}: "${v.letterSpacing}"`); + + const spacingLiteral = letterSpacing < 0 ? `(${letterSpacing})` : `${letterSpacing}`; + + classLines.push(` val ${size}: TextStyle = TextStyle(`); + classLines.push(` fontFamily = fontFamily,`); + classLines.push(` fontWeight = ${fontWeight},`); + classLines.push(` fontSize = ${fontSize}.sp,`); + classLines.push(` lineHeight = ${lineHeight}.sp,`); + classLines.push(` letterSpacing = ${spacingLiteral}.sp,`); + classLines.push(` lineHeightStyle = LineHeightStyle(`); + classLines.push(` alignment = LineHeightStyle.Alignment.Center,`); + classLines.push(` trim = LineHeightStyle.Trim.None,`); + classLines.push(` ),`); + if (isHeading) { + classLines.push(` lineBreak = LineBreak.Heading,`); + } + classLines.push(` )`); + } + + classLines.push(` }`); + innerClasses.push(classLines.join('\n')); + } return [ + FILE_SUPPRESS, + '', `package ${PACKAGE}`, '', + 'import androidx.compose.runtime.Stable', 'import androidx.compose.ui.text.TextStyle', + 'import androidx.compose.ui.text.font.FontFamily', 'import androidx.compose.ui.text.font.FontWeight', 'import androidx.compose.ui.text.style.LineBreak', 'import androidx.compose.ui.text.style.LineHeightStyle', 'import androidx.compose.ui.unit.sp', - 'import com.tangem.core.ui.res.InterFamily', '', '/**', ' * Auto-generated from design tokens. Do not edit manually.', ' */', - 'internal class TangemTypographyTokens {', - props.join('\n\n'), + '@Stable', + 'class TangemTypography3 internal constructor(fontFamily: FontFamily) {', + outerProps.join('\n'), + '', + innerClasses.join('\n\n'), '}', '', ].join('\n'); @@ -361,54 +684,88 @@ function mapFontWeight(value) { } /** - * Kotlin format for shadow tokens. - * Shadow tokens are composite (type: shadow) with object $value containing - * blur, spread, color, offsetX, offsetY, type. + * Kotlin format for TangemColors3 class definition. + * Generates the @Stable class hierarchy with mutableStateOf + update() pattern. + * Structure is derived from light theme codeSyntax.Android fields. */ StyleDictionary.registerFormat({ - name: 'kotlin/compose-shadows', + name: 'kotlin/compose-colors3-class', format: ({ dictionary }) => { - const shadowTokens = dictionary.allTokens.filter( - t => (t.$type === 'shadow' || t.$type === 'boxShadow') && !isSourceOnlyToken(t), + const colorTokens = dictionary.allTokens.filter( + t => t.$type === 'color' && !isSourceOnlyToken(t), ); - const entries = shadowTokens.map(token => { - const propName = toCamelCase(token.path); - const v = token.$value; - const sp = token.path.join('.'); - if (!v) throw new Error(`Missing shadow value for ${sp}`); - const blur = parseFloat(v.blur); - if (isNaN(blur)) throw new Error(`Non-numeric blur for ${sp}: "${v.blur}"`); - const spread = parseFloat(v.spread); - if (isNaN(spread)) throw new Error(`Non-numeric spread for ${sp}: "${v.spread}"`); - const offsetX = parseFloat(v.offsetX); - if (isNaN(offsetX)) throw new Error(`Non-numeric offsetX for ${sp}: "${v.offsetX}"`); - const offsetY = parseFloat(v.offsetY); - if (isNaN(offsetY)) throw new Error(`Non-numeric offsetY for ${sp}: "${v.offsetY}"`); - if (!v.color) throw new Error(`Missing color for ${sp}`); - const colorVal = toComposeColor(v.color, sp + '.color'); + colors3StructureTree = buildClassStructureTree(colorTokens); + resolveClassTreeConflicts(colors3StructureTree); - return [ - ` val ${propName}Blur = ${blur}.dp`, - ` val ${propName}OffsetX = ${offsetX}.dp`, - ` val ${propName}OffsetY = ${offsetY}.dp`, - ` val ${propName}Spread = ${spread}.dp`, - ` val ${propName}Color = ${colorVal}`, - ].join('\n'); - }); + const classLines = renderStableClass('TangemColors3', colors3StructureTree, 0); return [ + FILE_SUPPRESS, + '', `package ${PACKAGE}`, '', + 'import androidx.compose.runtime.Stable', + 'import androidx.compose.runtime.getValue', + 'import androidx.compose.runtime.mutableStateOf', + 'import androidx.compose.runtime.setValue', 'import androidx.compose.ui.graphics.Color', - 'import androidx.compose.ui.unit.dp', '', '/**', ' * Auto-generated from design tokens. Do not edit manually.', ' */', - 'internal class TangemShadowTokens {', - entries.join('\n\n'), - '}', + ...classLines, + '', + ].join('\n'); + }, +}); + +/** + * Kotlin format for TangemColors3 light/dark factory functions. + * Generates lightColors3() / darkColors3() functions referencing TangemColorPalette. + */ +StyleDictionary.registerFormat({ + name: 'kotlin/compose-colors3-factory', + format: ({ dictionary, options }) => { + const themeName = options.themeName; + const funcName = `${themeName.toLowerCase()}Colors3`; + + // Ensure tree is built (should already be set by class format in Light build) + if (!colors3StructureTree) { + const colorTokens = dictionary.allTokens.filter( + t => t.$type === 'color' && !isSourceOnlyToken(t), + ); + colors3StructureTree = buildClassStructureTree(colorTokens); + resolveClassTreeConflicts(colors3StructureTree); + } + + // Build value map: jsonPath → Kotlin palette reference or Color literal + const colorTokens = dictionary.allTokens.filter( + t => t.$type === 'color' && !isSourceOnlyToken(t), + ); + const valueMap = new Map(); + for (const token of colorTokens) { + const jsonPath = token.path.join('.'); + valueMap.set(jsonPath, paletteRefOrColor(token)); + } + + const bodyLines = renderFactoryContent('TangemColors3', colors3StructureTree, valueMap, 2); + + return [ + FILE_SUPPRESS, + '', + `package ${PACKAGE}`, + '', + 'import androidx.compose.ui.graphics.Color', + '', + '/**', + ` * Auto-generated from design tokens. Do not edit manually.`, + ` * Theme: ${themeName}`, + ' */', + `internal fun ${funcName}() =`, + ' TangemColors3(', + ...bodyLines, + ' )', '', ].join('\n'); }, @@ -425,6 +782,27 @@ const composePlatformTransforms = [ for (const [themeName, { sets }] of Object.entries(themeBuilds)) { console.log(`\nBuilding ${themeName} color tokens...`); + const colorFilter = token => token.$type === 'color' && !isSourceOnlyToken(token); + + const files = []; + + // Only light build generates the class definition (canonical codeSyntax structure) + if (themeName === 'Light') { + files.push({ + destination: 'TangemColors3.kt', + format: 'kotlin/compose-colors3-class', + filter: colorFilter, + }); + } + + // Both themes generate factory functions + files.push({ + destination: `TangemColors3${themeName}.kt`, + format: 'kotlin/compose-colors3-factory', + options: { themeName }, + filter: colorFilter, + }); + const sd = new StyleDictionary({ source: sets.map(s => path.join(tokensDir, `${s}.json`)), preprocessors: ['tokens-studio'], @@ -434,24 +812,44 @@ for (const [themeName, { sets }] of Object.entries(themeBuilds)) { compose: { transforms: composePlatformTransforms, buildPath: outputDir + '/', - files: [ - { - destination: `Tangem${themeName}ColorTokens.kt`, - format: 'kotlin/compose-colors', - options: { themeName }, - filter: token => token.$type === 'color' && !isSourceOnlyToken(token), - }, - ], + files, }, }, }); await sd.buildAllPlatforms(); - console.log(` ✓ Tangem${themeName}ColorTokens.kt`); + if (themeName === 'Light') console.log(' ✓ TangemColors3.kt'); + console.log(` ✓ TangemColors3${themeName}.kt`); } -// Build theme-independent tokens (dimensions, opacity, typography, shadows) -console.log('\nBuilding dimension, opacity, typography, and shadow tokens...'); +// Build palette tokens +console.log('\nBuilding palette tokens...'); + +const paletteSd = new StyleDictionary({ + source: [...coreSets, 'semantic/size/opacity'].map(s => path.join(tokensDir, `${s}.json`)), + preprocessors: ['tokens-studio'], + usesDtcg: true, + log: { warnings: 'disabled', errors: { brokenReferences: 'console' } }, + platforms: { + compose: { + transforms: composePlatformTransforms, + buildPath: outputDir + '/', + files: [ + { + destination: 'TangemColorPalette.kt', + format: 'kotlin/compose-palette', + filter: token => token.$type === 'color' && token.path[0] === 'palette', + }, + ], + }, + }, +}); + +await paletteSd.buildAllPlatforms(); +console.log(' ✓ TangemColorPalette.kt'); + +// Build theme-independent tokens (dimensions, typography) +console.log('\nBuilding dimension and typography tokens...'); const sd = new StyleDictionary({ source: sharedBuildSets.map(s => path.join(tokensDir, `${s}.json`)), @@ -464,42 +862,22 @@ const sd = new StyleDictionary({ buildPath: outputDir + '/', files: [ { - destination: 'TangemDimensionTokens.kt', - format: 'kotlin/compose-dimensions', - filter: token => { - const t = token.$type; - return ( - token.path[0] !== 'font' && - (t === 'dimension' || t === 'borderRadius' || t === 'borderWidth') && - !isSourceOnlyToken(token) - ); - }, + destination: 'TangemDimens3.kt', + format: 'kotlin/compose-dimens3', }, { - destination: 'TangemOpacityTokens.kt', - format: 'kotlin/compose-opacity', - filter: token => token.$type === 'opacity' && !isSourceOnlyToken(token), - }, - { - destination: 'TangemTypographyTokens.kt', - format: 'kotlin/compose-typography', + destination: 'TangemTypography3.kt', + format: 'kotlin/compose-typography3', filter: token => token.$type === 'typography' && !isSourceOnlyToken(token), }, - { - destination: 'TangemShadowTokens.kt', - format: 'kotlin/compose-shadows', - filter: token => (token.$type === 'shadow' || token.$type === 'boxShadow') && !isSourceOnlyToken(token), - }, ], }, }, }); await sd.buildAllPlatforms(); -console.log(' ✓ TangemDimensionTokens.kt'); -console.log(' ✓ TangemOpacityTokens.kt'); -console.log(' ✓ TangemTypographyTokens.kt'); -console.log(' ✓ TangemShadowTokens.kt'); +console.log(' ✓ TangemDimens3.kt'); +console.log(' ✓ TangemTypography3.kt'); // ── Write source hash ───────────────────────────────────────────────────────── // Hash all token JSON files so Gradle can verify generated code matches ds-tokens. @@ -513,7 +891,12 @@ function computeTokensHash() { } } walk(tokensDir); - files.sort(); // deterministic order + // Sort by relative path with forward slashes to match Gradle's invariantSeparatorsPath sorting + files.sort((a, b) => { + const ra = path.relative(tokensDir, a).split(path.sep).join('/'); + const rb = path.relative(tokensDir, b).split(path.sep).join('/'); + return ra.localeCompare(rb); + }); const hash = crypto.createHash('sha256'); for (const file of files) { From 91c2fb2d58acc50cfc533fdf21a0ba67b52c2913 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Apr 2026 14:10:17 +0300 Subject: [PATCH 140/206] Updated on 2026-08-14 --- .../details/utils/UserWalletSaverTest.kt | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt index 60fa5c7682..614ff92ea4 100644 --- a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/UserWalletSaverTest.kt @@ -55,17 +55,6 @@ internal class UserWalletSaverTest { coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } } - @Test - fun `GIVEN disclaimerWillShow WHEN scanAndSaveUserWallet THEN router pop AND no save`() = runTest { - mockScanCallback(callbackName = DISCLAIMER_WILL_SHOW) - - createSaver().scanAndSaveUserWallet(this) - - verify { router.pop(onComplete = any()) } - verify(exactly = 0) { messageSender.send(any()) } - coVerify(exactly = 0) { saveWalletUseCase.invoke(any(), any(), any()) } - } - @Test fun `GIVEN onCancel WHEN scanAndSaveUserWallet THEN no message AND no save`() = runTest { mockScanCallback(callbackName = ON_CANCEL) @@ -255,7 +244,6 @@ internal class UserWalletSaverTest { cardId = any(), onProgressStateChange = any(), onWalletNotCreated = any(), - disclaimerWillShow = any(), onCancel = any(), onFailure = any(), onSuccess = any(), @@ -263,7 +251,6 @@ internal class UserWalletSaverTest { } coAnswers { when (callbackName) { ON_WALLET_NOT_CREATED -> arg Unit>(ON_WALLET_NOT_CREATED_INDEX).invoke() - DISCLAIMER_WILL_SHOW -> arg<() -> Unit>(DISCLAIMER_WILL_SHOW_INDEX).invoke() ON_CANCEL -> arg Unit>(ON_CANCEL_INDEX).invoke() } } @@ -277,7 +264,6 @@ internal class UserWalletSaverTest { cardId = any(), onProgressStateChange = any(), onWalletNotCreated = any(), - disclaimerWillShow = any(), onCancel = any(), onFailure = any(), onSuccess = any(), @@ -295,7 +281,6 @@ internal class UserWalletSaverTest { cardId = any(), onProgressStateChange = any(), onWalletNotCreated = any(), - disclaimerWillShow = any(), onCancel = any(), onFailure = any(), onSuccess = any(), @@ -325,13 +310,11 @@ internal class UserWalletSaverTest { private companion object { const val ON_WALLET_NOT_CREATED = "onWalletNotCreated" - const val DISCLAIMER_WILL_SHOW = "disclaimerWillShow" const val ON_CANCEL = "onCancel" const val ON_WALLET_NOT_CREATED_INDEX = 4 - const val DISCLAIMER_WILL_SHOW_INDEX = 5 - const val ON_CANCEL_INDEX = 6 - const val ON_FAILURE_INDEX = 7 - const val ON_SUCCESS_INDEX = 8 + const val ON_CANCEL_INDEX = 5 + const val ON_FAILURE_INDEX = 6 + const val ON_SUCCESS_INDEX = 7 } } \ No newline at end of file From d718cf557d667472169a064d449d813c5efd65d2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Apr 2026 15:29:56 +0400 Subject: [PATCH 141/206] Updated on 2026-08-14 --- app/build.gradle.kts | 2 - .../java/com/tangem/tap/TangemApplication.kt | 17 - .../tangem/tap/common/log/FileLogWriter.kt | 36 ++ .../tangem/tap/common/log/LogcatLogWriter.kt | 86 +++++ .../common/log/TangemAppLoggerInitializer.kt | 109 +----- .../tap/common/log/TimberFormatStrategy.kt | 63 ---- .../tap/common/log/FileLogWriterTest.kt | 157 +++++++++ .../tap/common/log/LogcatLogWriterTest.kt | 213 ++++++++++++ .../tangem/datasource/api/common/Retrofit.kt | 28 -- .../datasource/local/logs/AppLogsStore.kt | 37 +-- core/utils/build.gradle.kts | 4 - .../com/tangem/utils/logging/BaseLogger.kt | 13 + .../tangem/utils/logging/LogTagResolver.kt | 35 ++ .../java/com/tangem/utils/logging/Severity.kt | 10 + .../com/tangem/utils/logging/TangemLogger.kt | 190 ++++++++--- .../utils/logging/LogTagResolverTest.kt | 100 ++++++ .../tangem/utils/logging/TangemLoggerTest.kt | 311 ++++++++++++++++++ gradle/dependencies.toml | 4 - 18 files changed, 1135 insertions(+), 280 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/common/log/FileLogWriter.kt create mode 100644 app/src/main/java/com/tangem/tap/common/log/LogcatLogWriter.kt delete mode 100644 app/src/main/java/com/tangem/tap/common/log/TimberFormatStrategy.kt create mode 100644 app/src/test/kotlin/com/tangem/tap/common/log/FileLogWriterTest.kt create mode 100644 app/src/test/kotlin/com/tangem/tap/common/log/LogcatLogWriterTest.kt create mode 100644 core/utils/src/main/java/com/tangem/utils/logging/BaseLogger.kt create mode 100644 core/utils/src/main/java/com/tangem/utils/logging/LogTagResolver.kt create mode 100644 core/utils/src/main/java/com/tangem/utils/logging/Severity.kt create mode 100644 core/utils/src/test/kotlin/com/tangem/utils/logging/LogTagResolverTest.kt create mode 100644 core/utils/src/test/kotlin/com/tangem/utils/logging/TangemLoggerTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 25cd960782..813bff1822 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -384,7 +384,6 @@ dependencies { implementation(deps.googlePlay.services) implementation(deps.googlePlay.advertising) coreLibraryDesugaring(deps.desugar) - implementation(deps.kermit) implementation(deps.zxing.qrCore) implementation(deps.coil) implementation(deps.coil.gif) @@ -405,7 +404,6 @@ dependencies { implementation(deps.kotlin.serialization) implementation(deps.reownCore) implementation(deps.reownWeb3) - implementation(deps.prettyLogger) implementation(deps.decompose.ext.compose) implementation(deps.moshi.adapters) implementation(deps.moshi.kotlin) diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index 61c9e07c43..d66468de37 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -164,7 +164,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. appScope.launch { launch(Dispatchers.IO) { loadNativeLibraries() - updateLogFiles() } } @@ -196,22 +195,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. ) } - private fun updateLogFiles() { - appLogsStore.deleteOldLogsFile() - - if (!BuildConfig.TESTER_MENU_ENABLED) { - appLogsStore.deleteLastLogFile() - } - - // Temporarily logs are not saved - // scope.launch { - // if (!appPreferencesStore.getSyncOrDefault(WAS_LOG_FILE_CLEARED, false)) { - // appLogsStore.deleteLastLogFile() - // appPreferencesStore.store(WAS_LOG_FILE_CLEARED, true) - // } - // } - } - override fun newImageLoader(): ImageLoader { return createCoilImageLoader( context = this, diff --git a/app/src/main/java/com/tangem/tap/common/log/FileLogWriter.kt b/app/src/main/java/com/tangem/tap/common/log/FileLogWriter.kt new file mode 100644 index 0000000000..600f325928 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/log/FileLogWriter.kt @@ -0,0 +1,36 @@ +package com.tangem.tap.common.log + +import com.tangem.datasource.local.logs.AppLogsStore +import com.tangem.utils.logging.Severity +import com.tangem.utils.logging.TangemLogger + +/** + * [TangemLogger.LogWriter] that persists log entries to [AppLogsStore]. + * + * Only [Severity.Error] and [Severity.Info] are written. The `shouldSanitize` flag is forwarded to + * [AppLogsStore.saveLogMessage], so callers that deliberately log unsanitized content + * (`shouldSanitize = false`) bypass the sanitizer. + */ +internal class FileLogWriter( + private val appLogsStore: AppLogsStore, +) : TangemLogger.LogWriter { + + override fun isLoggable(severity: Severity, tag: String): Boolean { + return severity == Severity.Error || severity == Severity.Info + } + + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + appLogsStore.saveLogMessage( + tag = tag, + message = message, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/LogcatLogWriter.kt b/app/src/main/java/com/tangem/tap/common/log/LogcatLogWriter.kt new file mode 100644 index 0000000000..76ac68af8b --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/log/LogcatLogWriter.kt @@ -0,0 +1,86 @@ +package com.tangem.tap.common.log + +import android.os.Build +import android.util.Log +import com.tangem.utils.logging.Severity +import com.tangem.utils.logging.TangemLogger + +/** + * [TangemLogger.LogWriter] that pretty-prints log entries to Logcat. + * + * Wraps each entry in unicode borders and chunks long messages so that they fit + * Android's per-entry byte limit (~4076 bytes). + */ +internal class LogcatLogWriter : TangemLogger.LogWriter { + + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + val priority = severity.toAndroidPriority() + val truncatedTag = tag.truncateForLogcat() + val finalMessage = if (throwable != null) { + "$message\n${Log.getStackTraceString(throwable)}" + } else { + message + } + printBoxed(priority, truncatedTag, finalMessage) + } + + private fun printBoxed(priority: Int, tag: String, message: String) { + Log.println(priority, tag, TOP_BORDER) + val bytes = message.toByteArray() + val length = bytes.size + if (length <= CHUNK_SIZE) { + printContent(priority, tag, message) + } else { + var i = 0 + while (i < length) { + val count = (length - i).coerceAtMost(CHUNK_SIZE) + printContent(priority, tag, String(bytes, i, count)) + i += CHUNK_SIZE + } + } + Log.println(priority, tag, BOTTOM_BORDER) + } + + private fun printContent(priority: Int, tag: String, chunk: String) { + chunk.split(System.lineSeparator()).forEach { line -> + Log.println(priority, tag, "$HORIZONTAL_LINE $line") + } + } + + @Suppress("MagicNumber") + private fun String.truncateForLogcat(): String { + // Tag length limit was removed in API 26. + return if (length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { + this + } else { + substring(0, MAX_TAG_LENGTH) + } + } + + private fun Severity.toAndroidPriority(): Int = when (this) { + Severity.Verbose -> Log.VERBOSE + Severity.Debug -> Log.DEBUG + Severity.Info -> Log.INFO + Severity.Warn -> Log.WARN + Severity.Error -> Log.ERROR + Severity.Assert -> Log.ASSERT + } + + private companion object { + // Android's max per-entry byte limit is ~4076; leave headroom for borders. + const val CHUNK_SIZE = 4000 + + const val MAX_TAG_LENGTH = 23 + + const val HORIZONTAL_LINE = "│" + const val DIVIDER = "────────────────────────────────────────────────────────" + const val TOP_BORDER = "┌$DIVIDER$DIVIDER" + const val BOTTOM_BORDER = "└$DIVIDER$DIVIDER" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt index 0231c5fb23..b71a74125c 100644 --- a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt @@ -1,17 +1,8 @@ package com.tangem.tap.common.log -import android.os.Build -import android.util.Log -import co.touchlab.kermit.BaseLogger -import co.touchlab.kermit.LogWriter -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import com.orhanobut.logger.AndroidLogAdapter import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.BuildConfig -import java.util.regex.Pattern -import com.orhanobut.logger.Logger as PrettyLogger /** * Tangem app logger @@ -24,98 +15,14 @@ class TangemAppLoggerInitializer( private val appLogsStore: AppLogsStore, ) { - /** Initialize */ fun initialize() { - if (IS_LOG_ENABLED) { - PrettyLogger.addLogAdapter(AndroidLogAdapter(TimberFormatStrategy())) - } - - Logger.setLogWriters(KermitLogWriter(::finalLogOutput)) - } - - private fun finalLogOutput(priority: Int, tag: String?, message: String, t: Throwable?) { - if (IS_LOG_ENABLED) { - PrettyLogger.log(priority, tag, message, t) - } - - if (PERMITTED_PRIORITY.contains(priority)) { - appLogsStore.saveLogMessage( - tag = tag ?: "TangemAppLogger", - message = message, - ) - } - } - - @Suppress("BooleanPropertyNaming") - private companion object { - val IS_LOG_ENABLED: Boolean = BuildConfig.LOG_ENABLED - val PERMITTED_PRIORITY = listOf(Log.ERROR, Log.INFO) - } -} - -private class KermitLogWriter( - private val finalLogOutput: (priority: Int, tag: String?, message: String, t: Throwable?) -> Unit, -) : LogWriter() { - - private val fqcnIgnore = setOf( - LogWriter::class.java.name, - KermitLogWriter::class.java.name, - BaseLogger::class.java.name, - Logger::class.java.name, - TangemLogger::class.java.name, - TangemLogger.TaggedLogger::class.java.name, - ) - - override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { - val priority = when (severity) { - Severity.Verbose -> PrettyLogger.VERBOSE - Severity.Debug -> PrettyLogger.DEBUG - Severity.Info -> PrettyLogger.INFO - Severity.Warn -> PrettyLogger.WARN - Severity.Error -> PrettyLogger.ERROR - Severity.Assert -> PrettyLogger.ASSERT - } - - val finalTag = if (tag != KERMIT_LOGGER_DEFAULT_TAG) { - tag - } else { - /** - * like in [Logger.debugTree.tag] - */ - @Suppress("UnnecessaryLet", "ThrowingExceptionsWithoutMessageOrCause") - Throwable().stackTrace - .first { it.className !in fqcnIgnore } - .let(::createStackElementTag) - } - - finalLogOutput(priority, finalTag, message, throwable) - } - - /** - * copy from [Logger.debugTree.createStackElementTag] - */ - @Suppress("MagicNumber") - private fun createStackElementTag(element: StackTraceElement): String? { - var tag = element.className.substringAfterLast('.') - val m = ANONYMOUS_CLASS.matcher(tag) - if (m.find()) { - tag = m.replaceAll("") - } - // Tag length limit was removed in API 26. - return if (tag.length <= MAX_TAG_LENGTH || Build.VERSION.SDK_INT >= 26) { - tag - } else { - tag.substring(0, MAX_TAG_LENGTH) - } - } - - private companion object { - private const val KERMIT_LOGGER_DEFAULT_TAG = "" - - /** - * copy from [Logger.debugTree.Companion] - */ - private const val MAX_TAG_LENGTH = 23 - private val ANONYMOUS_CLASS = Pattern.compile("(\\$\\d+)+$") + TangemLogger.setLogWriters( + buildList { + if (BuildConfig.LOG_ENABLED) { + add(LogcatLogWriter()) + } + add(FileLogWriter(appLogsStore)) + }, + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TimberFormatStrategy.kt b/app/src/main/java/com/tangem/tap/common/log/TimberFormatStrategy.kt deleted file mode 100644 index 0a43f525d4..0000000000 --- a/app/src/main/java/com/tangem/tap/common/log/TimberFormatStrategy.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.tangem.tap.common.log - -import com.orhanobut.logger.FormatStrategy -import com.orhanobut.logger.LogStrategy -import com.orhanobut.logger.LogcatLogStrategy - -class TimberFormatStrategy : FormatStrategy { - - private val logStrategy: LogStrategy = LogcatLogStrategy() - - override fun log(priority: Int, tag: String?, message: String) { - logTopBorder(priority, tag) - val bytes = message.toByteArray() - val length = bytes.size - if (length <= CHUNK_SIZE) { - logContent(priority, tag, message) - logBottomBorder(priority, tag) - return - } - var i = 0 - while (i < length) { - val count = (length - i).coerceAtMost(CHUNK_SIZE) - // create a new String with system's default charset (which is UTF-8 for Android) - logContent(priority, tag, String(bytes, i, count)) - i += CHUNK_SIZE - } - logBottomBorder(priority, tag) - } - - private fun logTopBorder(logType: Int, tag: String?) { - logChunk(logType, tag, TOP_BORDER) - } - - private fun logBottomBorder(logType: Int, tag: String?) { - logChunk(logType, tag, BOTTOM_BORDER) - } - - private fun logContent(logType: Int, tag: String?, chunk: String) { - chunk.split(System.lineSeparator()).forEach { line -> - logChunk(logType, tag, "$HORIZONTAL_LINE $line") - } - } - - private fun logChunk(priority: Int, tag: String?, chunk: String) { - logStrategy.log(priority, tag, chunk) - } - - private companion object { - /** - * Android's max limit for a log entry is ~4076 bytes, - * so 4000 bytes is used as chunk size since default charset - * is UTF-8 - */ - private const val CHUNK_SIZE = 4000 - - const val TOP_LEFT_CORNER = "┌" - const val BOTTOM_LEFT_CORNER = "└" - const val HORIZONTAL_LINE = "│" - const val DOUBLE_DIVIDER = "────────────────────────────────────────────────────────" - const val TOP_BORDER = TOP_LEFT_CORNER + DOUBLE_DIVIDER + DOUBLE_DIVIDER - const val BOTTOM_BORDER = BOTTOM_LEFT_CORNER + DOUBLE_DIVIDER + DOUBLE_DIVIDER - } -} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/log/FileLogWriterTest.kt b/app/src/test/kotlin/com/tangem/tap/common/log/FileLogWriterTest.kt new file mode 100644 index 0000000000..ea9d224b42 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/log/FileLogWriterTest.kt @@ -0,0 +1,157 @@ +package com.tangem.tap.common.log + +import com.google.common.truth.Truth +import com.tangem.datasource.local.logs.AppLogsStore +import com.tangem.utils.logging.Severity +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FileLogWriterTest { + + private val appLogsStore: AppLogsStore = mockk(relaxUnitFun = true) + private val writer = FileLogWriter(appLogsStore) + + // region isLoggable filter + + @Test + fun `isLoggable returns true for Error severity`() { + Truth.assertThat(writer.isLoggable(Severity.Error, "tag")).isTrue() + } + + @Test + fun `isLoggable returns true for Info severity`() { + Truth.assertThat(writer.isLoggable(Severity.Info, "tag")).isTrue() + } + + @Test + fun `isLoggable returns false for Verbose severity`() { + Truth.assertThat(writer.isLoggable(Severity.Verbose, "tag")).isFalse() + } + + @Test + fun `isLoggable returns false for Debug severity`() { + Truth.assertThat(writer.isLoggable(Severity.Debug, "tag")).isFalse() + } + + @Test + fun `isLoggable returns false for Warn severity`() { + Truth.assertThat(writer.isLoggable(Severity.Warn, "tag")).isFalse() + } + + @Test + fun `isLoggable returns false for Assert severity`() { + Truth.assertThat(writer.isLoggable(Severity.Assert, "tag")).isFalse() + } + + @Test + fun `isLoggable result is independent of the tag value`() { + Truth.assertThat(writer.isLoggable(Severity.Info, "")).isTrue() + Truth.assertThat(writer.isLoggable(Severity.Info, "anything")).isTrue() + Truth.assertThat(writer.isLoggable(Severity.Debug, "")).isFalse() + Truth.assertThat(writer.isLoggable(Severity.Debug, "anything")).isFalse() + } + + // endregion + + // region write delegation + + @Test + fun `write forwards tag, message, throwable and shouldSanitize to AppLogsStore`() { + // Arrange + val throwable = IllegalStateException("boom") + + // Act + writer.write( + severity = Severity.Error, + tag = "MyTag", + message = "error happened", + throwable = throwable, + shouldSanitize = true, + ) + + // Assert + verify(exactly = 1) { + appLogsStore.saveLogMessage( + tag = "MyTag", + message = "error happened", + throwable = throwable, + shouldSanitize = true, + ) + } + } + + @Test + fun `write forwards null throwable as null`() { + // Act + writer.write( + severity = Severity.Info, + tag = "Tag", + message = "info", + throwable = null, + shouldSanitize = true, + ) + + // Assert + verify(exactly = 1) { + appLogsStore.saveLogMessage( + tag = "Tag", + message = "info", + throwable = null, + shouldSanitize = true, + ) + } + } + + @Test + fun `write forwards shouldSanitize false to AppLogsStore so sanitizer is bypassed`() { + // Act + writer.write( + severity = Severity.Info, + tag = "Tag", + message = "raw payload", + throwable = null, + shouldSanitize = false, + ) + + // Assert + verify(exactly = 1) { + appLogsStore.saveLogMessage( + tag = "Tag", + message = "raw payload", + throwable = null, + shouldSanitize = false, + ) + } + } + + @Test + fun `write delegates regardless of severity (filtering is the caller's job)`() { + // The contract: TangemLogger asks isLoggable first; if a caller bypasses that and + // invokes write directly, the writer should still delegate to the store. + Severity.entries.forEach { severity -> + // Act + writer.write( + severity = severity, + tag = "Tag", + message = "msg-$severity", + throwable = null, + shouldSanitize = true, + ) + + // Assert + verify(exactly = 1) { + appLogsStore.saveLogMessage( + tag = "Tag", + message = "msg-$severity", + throwable = null, + shouldSanitize = true, + ) + } + } + } + + // endregion +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/common/log/LogcatLogWriterTest.kt b/app/src/test/kotlin/com/tangem/tap/common/log/LogcatLogWriterTest.kt new file mode 100644 index 0000000000..57a2b8fb7d --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/common/log/LogcatLogWriterTest.kt @@ -0,0 +1,213 @@ +package com.tangem.tap.common.log + +import android.util.Log +import com.tangem.utils.logging.Severity +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class LogcatLogWriterTest { + + private val writer = LogcatLogWriter() + + @BeforeEach + fun setUp() { + mockkStatic(Log::class) + every { Log.println(any(), any(), any()) } returns 0 + every { Log.getStackTraceString(any()) } returns "STACK" + } + + @AfterEach + fun tearDown() { + unmockkStatic(Log::class) + } + + // region Severity → Android priority mapping + + @Test + fun `Verbose severity maps to Log VERBOSE priority`() { + // Act + writer.write(Severity.Verbose, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.VERBOSE, "Tag", any()) } + } + + @Test + fun `Debug severity maps to Log DEBUG priority`() { + // Act + writer.write(Severity.Debug, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.DEBUG, "Tag", any()) } + } + + @Test + fun `Info severity maps to Log INFO priority`() { + // Act + writer.write(Severity.Info, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.INFO, "Tag", any()) } + } + + @Test + fun `Warn severity maps to Log WARN priority`() { + // Act + writer.write(Severity.Warn, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.WARN, "Tag", any()) } + } + + @Test + fun `Error severity maps to Log ERROR priority`() { + // Act + writer.write(Severity.Error, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.ERROR, "Tag", any()) } + } + + @Test + fun `Assert severity maps to Log ASSERT priority`() { + // Act + writer.write(Severity.Assert, "Tag", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.ASSERT, "Tag", any()) } + } + + // endregion + + // region Box layout + + @Test + fun `single-line message is wrapped between top and bottom borders`() { + // Act + writer.write(Severity.Info, "Tag", "hello", throwable = null, shouldSanitize = true) + + // Assert + verifySequence { + Log.println(Log.INFO, "Tag", match { it.startsWith("┌") }) + Log.println(Log.INFO, "Tag", "│ hello") + Log.println(Log.INFO, "Tag", match { it.startsWith("└") }) + } + } + + @Test + fun `each line of a multi-line message is printed as a separate logcat entry`() { + // Arrange + val sep = System.lineSeparator() + val message = "first${sep}second${sep}third" + + // Act + writer.write(Severity.Debug, "Tag", message, throwable = null, shouldSanitize = true) + + // Assert + verifySequence { + Log.println(Log.DEBUG, "Tag", match { it.startsWith("┌") }) + Log.println(Log.DEBUG, "Tag", "│ first") + Log.println(Log.DEBUG, "Tag", "│ second") + Log.println(Log.DEBUG, "Tag", "│ third") + Log.println(Log.DEBUG, "Tag", match { it.startsWith("└") }) + } + } + + // endregion + + // region Throwable handling + + @Test + fun `throwable is rendered via Log getStackTraceString`() { + // Arrange + val throwable = RuntimeException("boom") + every { Log.getStackTraceString(throwable) } returns "STACK" + + // Act + writer.write(Severity.Error, "Tag", "fail", throwable = throwable, shouldSanitize = true) + + // Assert + verify(exactly = 1) { Log.getStackTraceString(throwable) } + } + + @Test + fun `null throwable does not invoke getStackTraceString`() { + // Act + writer.write(Severity.Info, "Tag", "no throwable", throwable = null, shouldSanitize = true) + + // Assert + verify(exactly = 0) { Log.getStackTraceString(any()) } + } + + // endregion + + // region Chunking of long messages + + @Test + fun `message under CHUNK_SIZE bytes produces a single content line`() { + // Arrange + val message = "a".repeat(3999) + + // Act + writer.write(Severity.Info, "Tag", message, throwable = null, shouldSanitize = true) + + // Assert — top border + 1 content line + bottom border + verify(exactly = 3) { Log.println(Log.INFO, "Tag", any()) } + } + + @Test + fun `message exceeding CHUNK_SIZE bytes is split into multiple chunks`() { + // Arrange — 9000 ASCII bytes → chunks of 4000 + 4000 + 1000 = 3 chunks + val message = "a".repeat(9000) + + // Act + writer.write(Severity.Info, "Tag", message, throwable = null, shouldSanitize = true) + + // Assert — top border + 3 content lines + bottom border + verify(exactly = 5) { Log.println(Log.INFO, "Tag", any()) } + } + + // endregion + + // region Tag truncation + + @Test + fun `tag longer than 23 chars is truncated on legacy Android API stub`() { + // Arrange — in the unit-test Android stub, Build.VERSION.SDK_INT == 0, + // triggering the legacy truncation path. + val longTag = "a".repeat(50) + + // Act + writer.write(Severity.Info, longTag, "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.INFO, "a".repeat(23), any()) } + } + + @Test + fun `tag of MAX_TAG_LENGTH chars is not truncated`() { + // Arrange + val tag = "a".repeat(23) + + // Act + writer.write(Severity.Info, tag, "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.INFO, "a".repeat(23), any()) } + } + + @Test + fun `short tag is forwarded verbatim`() { + // Act + writer.write(Severity.Info, "Short", "msg", throwable = null, shouldSanitize = true) + + // Assert + verify { Log.println(Log.INFO, "Short", any()) } + } + + // endregion +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/Retrofit.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/Retrofit.kt index 830cc43c53..507dd085c9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/Retrofit.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/Retrofit.kt @@ -4,34 +4,6 @@ import android.util.Log import com.ihsanbal.logging.Level import com.ihsanbal.logging.LoggingInterceptor import okhttp3.Interceptor -import okhttp3.OkHttpClient -import retrofit2.Retrofit -import java.util.concurrent.TimeUnit - -@Suppress("MagicNumber") -@Deprecated("Create and provide by DI") -fun createRetrofitInstance( - baseUrl: String, - okHttpBuilder: OkHttpClient.Builder = OkHttpClient.Builder(), - interceptors: List = emptyList(), - logEnabled: Boolean, -): Retrofit { - okHttpBuilder.apply { - callTimeout(10, TimeUnit.SECONDS) - connectTimeout(20, TimeUnit.SECONDS) - readTimeout(20, TimeUnit.SECONDS) - writeTimeout(20, TimeUnit.SECONDS) - } - interceptors.forEach { okHttpBuilder.addInterceptor(it) } - - if (logEnabled) okHttpBuilder.addInterceptor(createNetworkLoggingInterceptor()) - - return Retrofit.Builder() - .baseUrl(baseUrl) - .addConverterFactory(MoshiConverter.networkMoshiConverter) - .client(okHttpBuilder.build()) - .build() -} fun createNetworkLoggingInterceptor(): Interceptor { return LoggingInterceptor.Builder() diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt index 65dd45cf46..1704e959a9 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/logs/AppLogsStore.kt @@ -70,12 +70,17 @@ class AppLogsStore @Inject constructor( } } - /** Save log [message] */ - fun saveLogMessage(tag: String, message: String) { + /** + * Save log [message]. Pass [shouldSanitize] = false to bypass [LogsSanitizer]. + * The optional [throwable]'s stack trace is appended verbatim (never sanitized), + * since stack traces routinely contain hex-like sequences that the sanitizer would + * otherwise destroy. + */ + fun saveLogMessage(tag: String, message: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) { launchWithLock { createFileIfNotExist() - writeMessage(tag = tag, message) + writeMessage(tag = tag, shouldSanitize = shouldSanitize, throwable = throwable, message) } } @@ -84,7 +89,7 @@ class AppLogsStore @Inject constructor( launchWithLock { createFileIfNotExist() - writeMessage(tag = tag, *messages) + writeMessage(tag = tag, shouldSanitize = true, throwable = null, messages = messages) } } @@ -97,24 +102,16 @@ class AppLogsStore @Inject constructor( } } - fun deleteOldLogsFile() { - val file = File(applicationContext.filesDir, LOG_FILE_NAME) - - if (file.exists()) file.delete() - } - - fun deleteLastLogFile() { - val file = File(applicationContext.filesDir, NEW_LOG_FILE_NAME) - - if (file.exists()) file.delete() - } - - private fun writeMessage(tag: String, vararg messages: String) { + private fun writeMessage(tag: String, shouldSanitize: Boolean, throwable: Throwable?, vararg messages: String) { BufferedWriter(FileWriter(logFile, true)).use { writer -> writer.append(formatter.print(DateTime.now())) writer.append(": $tag ") - messages.map(LogsSanitizer::sanitize) - .forEach(writer::append) + val processed = if (shouldSanitize) messages.map(LogsSanitizer::sanitize) else messages.toList() + processed.forEach(writer::append) + if (throwable != null) { + writer.newLine() + writer.append(throwable.stackTraceToString().trimEnd()) + } writer.newLine() } } @@ -166,8 +163,6 @@ class AppLogsStore @Inject constructor( private companion object { const val BUFFER_SIZE = 1024 - const val LOG_FILE_NAME = "logs.txt" - const val NEW_LOG_FILE_NAME = "app_logs.txt" // the only name that we allow to send as email to company addresses const val PERMITTED_FILE_NAME = "log.txt" const val PERMITTED_FILE_NAME_ZIP = "log.zip" diff --git a/core/utils/build.gradle.kts b/core/utils/build.gradle.kts index 0d59823349..48e632eff4 100644 --- a/core/utils/build.gradle.kts +++ b/core/utils/build.gradle.kts @@ -23,10 +23,6 @@ dependencies { implementation(deps.jodatime) // endregion - // region Logging - implementation(deps.kermit) - // endregion - testImplementation(deps.test.coroutine) testImplementation(deps.test.junit5) testRuntimeOnly(deps.test.junit5.engine) diff --git a/core/utils/src/main/java/com/tangem/utils/logging/BaseLogger.kt b/core/utils/src/main/java/com/tangem/utils/logging/BaseLogger.kt new file mode 100644 index 0000000000..dca1140ca6 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/logging/BaseLogger.kt @@ -0,0 +1,13 @@ +package com.tangem.utils.logging + +/** + * Common contract for application loggers. + */ +internal interface BaseLogger { + fun v(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun d(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun i(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun w(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun e(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) + fun a(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true) +} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/logging/LogTagResolver.kt b/core/utils/src/main/java/com/tangem/utils/logging/LogTagResolver.kt new file mode 100644 index 0000000000..0e0228c472 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/logging/LogTagResolver.kt @@ -0,0 +1,35 @@ +package com.tangem.utils.logging + +import java.util.regex.Pattern + +/** + * Resolves a log tag from the call site's class name when the caller didn't supply one + * via [TangemLogger.withTag]. Used by [TangemLogger.write] before dispatching to writers. + */ +internal object LogTagResolver { + + private const val FALLBACK_TAG = "TangemAppLogger" + + private val ANONYMOUS_CLASS_REGEX: Pattern = Pattern.compile("(\\$\\d+)+$") + + private val FQCN_IGNORE = setOf( + LogTagResolver::class.java.name, + TangemLogger::class.java.name, + TangemLogger.TaggedLogger::class.java.name, + // Synthetic class generated for BaseLogger's default-arg trampolines (d$default, etc.). + // Without this, every call that omits default args resolves to BaseLogger.DefaultImpls. + "${BaseLogger::class.java.name}\$DefaultImpls", + ) + + @Suppress("ThrowingExceptionsWithoutMessageOrCause") + fun resolveTag(): String { + val element = Throwable().stackTrace.firstOrNull { it.className !in FQCN_IGNORE } + ?: return FALLBACK_TAG + var tag = element.className.substringAfterLast('.') + val matcher = ANONYMOUS_CLASS_REGEX.matcher(tag) + if (matcher.find()) { + tag = matcher.replaceAll("") + } + return tag + } +} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/logging/Severity.kt b/core/utils/src/main/java/com/tangem/utils/logging/Severity.kt new file mode 100644 index 0000000000..52f9e62a20 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/logging/Severity.kt @@ -0,0 +1,10 @@ +package com.tangem.utils.logging + +enum class Severity { + Verbose, + Debug, + Info, + Warn, + Error, + Assert, +} \ No newline at end of file diff --git a/core/utils/src/main/java/com/tangem/utils/logging/TangemLogger.kt b/core/utils/src/main/java/com/tangem/utils/logging/TangemLogger.kt index 5bd1fab06f..c7a8aa10ef 100644 --- a/core/utils/src/main/java/com/tangem/utils/logging/TangemLogger.kt +++ b/core/utils/src/main/java/com/tangem/utils/logging/TangemLogger.kt @@ -1,63 +1,173 @@ package com.tangem.utils.logging -import co.touchlab.kermit.Logger +import java.util.concurrent.CopyOnWriteArrayList /** - * Application-level logger that wraps Kermit [Logger] with the same API. - * All modules should use [TangemLogger] instead of importing Kermit directly. + * Application-level logger */ -object TangemLogger { +object TangemLogger : BaseLogger { - fun v(messageString: String, throwable: Throwable? = null) { - Logger.v(messageString, throwable) + private val logWriters = CopyOnWriteArrayList() + + fun setLogWriters(writers: List) { + logWriters.clear() + logWriters.addAll(writers) } - fun d(messageString: String, throwable: Throwable? = null) { - Logger.d(messageString, throwable) + fun addLogWriter(writer: LogWriter) { + logWriters.add(writer) } - fun i(messageString: String, throwable: Throwable? = null) { - Logger.i(messageString, throwable) + override fun v(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Verbose, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) } - fun w(messageString: String, throwable: Throwable? = null) { - Logger.w(messageString, throwable) + override fun d(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Debug, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) } - fun e(messageString: String, throwable: Throwable? = null) { - Logger.e(messageString, throwable) + override fun i(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Info, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) } - fun a(messageString: String, throwable: Throwable? = null) { - Logger.a(messageString, throwable) + override fun w(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Warn, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun e(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Error, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun a(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Assert, + tag = null, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) } fun withTag(tag: String): TaggedLogger = TaggedLogger(tag) - class TaggedLogger internal constructor(private val tag: String) { - - fun v(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).v(messageString, throwable) - } - - fun d(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).d(messageString, throwable) - } - - fun i(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).i(messageString, throwable) - } - - fun w(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).w(messageString, throwable) - } - - fun e(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).e(messageString, throwable) - } - - fun a(messageString: String, throwable: Throwable? = null) { - Logger.withTag(tag).a(messageString, throwable) + private fun write( + severity: Severity, + tag: String?, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + val resolvedTag = tag ?: LogTagResolver.resolveTag() + logWriters.forEach { writer -> + if (writer.isLoggable(severity, resolvedTag)) { + writer.write( + severity = severity, + tag = resolvedTag, + message = message, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } } } + + class TaggedLogger internal constructor(private val tag: String) : BaseLogger { + + override fun v(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Verbose, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun d(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Debug, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun i(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Info, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun w(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Warn, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun e(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Error, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + + override fun a(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) { + write( + severity = Severity.Assert, + tag = tag, + message = messageString, + throwable = throwable, + shouldSanitize = shouldSanitize, + ) + } + } + + interface LogWriter { + + fun isLoggable(severity: Severity, tag: String): Boolean = true + + fun write(severity: Severity, tag: String, message: String, throwable: Throwable?, shouldSanitize: Boolean) + } } \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/logging/LogTagResolverTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/logging/LogTagResolverTest.kt new file mode 100644 index 0000000000..194944c929 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/logging/LogTagResolverTest.kt @@ -0,0 +1,100 @@ +package com.tangem.utils.logging + +import com.google.common.truth.Truth +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class LogTagResolverTest { + + @AfterEach + fun tearDown() { + // Clean up shared TangemLogger state used in some cases + TangemLogger.setLogWriters(emptyList()) + } + + @Test + fun `resolveTag returns the simple class name of the direct caller`() { + // Act + val tag = LogTagResolver.resolveTag() + + // Assert + Truth.assertThat(tag).isEqualTo("LogTagResolverTest") + } + + @Test + fun `resolveTag does not include package qualifier`() { + // Act + val tag = LogTagResolver.resolveTag() + + // Assert + Truth.assertThat(tag).doesNotContain(".") + } + + @Test + fun `resolveTag never returns its own class name`() { + // Act + val tag = LogTagResolver.resolveTag() + + // Assert + Truth.assertThat(tag).isNotEqualTo("LogTagResolver") + } + + @Test + fun `resolveTag skips TangemLogger frames when invoked through it`() { + // Arrange + var capturedTag: String? = null + val writer = object : TangemLogger.LogWriter { + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + capturedTag = tag + } + } + TangemLogger.setLogWriters(listOf(writer)) + + // Act + TangemLogger.d("via TangemLogger") + + // Assert — TangemLogger and LogTagResolver are filtered, leaving the test class + Truth.assertThat(capturedTag).isEqualTo("LogTagResolverTest") + } + + @Test + fun `resolveTag is bypassed by TaggedLogger when an explicit tag is supplied`() { + // Arrange + var capturedTag: String? = null + val writer = object : TangemLogger.LogWriter { + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) { + capturedTag = tag + } + } + TangemLogger.setLogWriters(listOf(writer)) + + // Act + TangemLogger.withTag("ExplicitTag").d("hi") + + // Assert + Truth.assertThat(capturedTag).isEqualTo("ExplicitTag") + } + + @Test + fun `resolveTag returns a non-empty string`() { + // Act + val tag = LogTagResolver.resolveTag() + + // Assert + Truth.assertThat(tag).isNotEmpty() + } +} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/logging/TangemLoggerTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/logging/TangemLoggerTest.kt new file mode 100644 index 0000000000..a1c42eae34 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/logging/TangemLoggerTest.kt @@ -0,0 +1,311 @@ +package com.tangem.utils.logging + +import com.google.common.truth.Truth +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TangemLoggerTest { + + private lateinit var writer: TangemLogger.LogWriter + + @BeforeEach + fun setUp() { + writer = mockk(relaxed = true) + every { writer.isLoggable(any(), any()) } returns true + TangemLogger.setLogWriters(listOf(writer)) + } + + @AfterEach + fun tearDown() { + // Reset singleton state to avoid cross-test pollution + TangemLogger.setLogWriters(emptyList()) + } + + // region Severity dispatch + + @Test + fun `v dispatches Verbose severity to writer`() { + // Act + TangemLogger.v("verbose message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Verbose, any(), "verbose message", null, true) + } + } + + @Test + fun `d dispatches Debug severity to writer`() { + // Act + TangemLogger.d("debug message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Debug, any(), "debug message", null, true) + } + } + + @Test + fun `i dispatches Info severity to writer`() { + // Act + TangemLogger.i("info message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Info, any(), "info message", null, true) + } + } + + @Test + fun `w dispatches Warn severity to writer`() { + // Act + TangemLogger.w("warn message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Warn, any(), "warn message", null, true) + } + } + + @Test + fun `e dispatches Error severity to writer`() { + // Act + TangemLogger.e("error message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Error, any(), "error message", null, true) + } + } + + @Test + fun `a dispatches Assert severity to writer`() { + // Act + TangemLogger.a("assert message") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Assert, any(), "assert message", null, true) + } + } + + // endregion + + // region Throwable & shouldSanitize propagation + + @Test + fun `throwable parameter is forwarded to writer`() { + // Arrange + val throwable = IllegalStateException("boom") + + // Act + TangemLogger.e("error", throwable) + + // Assert + verify(exactly = 1) { + writer.write(Severity.Error, any(), "error", throwable, true) + } + } + + @Test + fun `shouldSanitize flag is forwarded to writer`() { + // Act + TangemLogger.w("not checked", shouldSanitize = false) + + // Assert + verify(exactly = 1) { + writer.write(Severity.Warn, any(), "not checked", null, false) + } + } + + // endregion + + // region setLogWriters / addLogWriter + + @Test + fun `setLogWriters replaces previously registered writers`() { + // Arrange + val previous: TangemLogger.LogWriter = mockk(relaxed = true) + every { previous.isLoggable(any(), any()) } returns true + val replacement: TangemLogger.LogWriter = mockk(relaxed = true) + every { replacement.isLoggable(any(), any()) } returns true + + TangemLogger.setLogWriters(listOf(previous)) + TangemLogger.setLogWriters(listOf(replacement)) + + // Act + TangemLogger.i("after replace") + + // Assert + verify(exactly = 0) { previous.write(any(), any(), any(), any(), any()) } + verify(exactly = 1) { + replacement.write(Severity.Info, any(), "after replace", null, true) + } + } + + @Test + fun `addLogWriter appends without removing existing writers`() { + // Arrange + val first: TangemLogger.LogWriter = mockk(relaxed = true) + every { first.isLoggable(any(), any()) } returns true + val second: TangemLogger.LogWriter = mockk(relaxed = true) + every { second.isLoggable(any(), any()) } returns true + + TangemLogger.setLogWriters(listOf(first)) + TangemLogger.addLogWriter(second) + + // Act + TangemLogger.d("broadcast") + + // Assert + verify(exactly = 1) { first.write(Severity.Debug, any(), "broadcast", null, true) } + verify(exactly = 1) { second.write(Severity.Debug, any(), "broadcast", null, true) } + } + + @Test + fun `setLogWriters with empty list silences all output`() { + // Arrange + TangemLogger.setLogWriters(emptyList()) + + // Act + TangemLogger.i("nobody listening") + + // Assert + verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) } + } + + // endregion + + // region isLoggable filtering + + @Test + fun `write is skipped when isLoggable returns false`() { + // Arrange + every { writer.isLoggable(any(), any()) } returns false + + // Act + TangemLogger.w("filtered out") + + // Assert + verify(exactly = 1) { writer.isLoggable(Severity.Warn, any()) } + verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) } + } + + @Test + fun `each writer is filtered independently by its own isLoggable`() { + // Arrange + val accepting: TangemLogger.LogWriter = mockk(relaxed = true) + every { accepting.isLoggable(any(), any()) } returns true + val rejecting: TangemLogger.LogWriter = mockk(relaxed = true) + every { rejecting.isLoggable(any(), any()) } returns false + + TangemLogger.setLogWriters(listOf(accepting, rejecting)) + + // Act + TangemLogger.i("partial") + + // Assert + verify(exactly = 1) { accepting.write(Severity.Info, any(), "partial", null, true) } + verify(exactly = 0) { rejecting.write(any(), any(), any(), any(), any()) } + } + + @Test + fun `LogWriter isLoggable defaults to true`() { + // Arrange + val realWriter = object : TangemLogger.LogWriter { + override fun write( + severity: Severity, + tag: String, + message: String, + throwable: Throwable?, + shouldSanitize: Boolean, + ) = Unit + } + + // Act + Assert + Severity.entries.forEach { severity -> + Truth.assertThat(realWriter.isLoggable(severity, "anyTag")).isTrue() + } + } + + // endregion + + // region Tag resolution + + @Test + fun `resolved tag falls back to caller class name when no tag is provided`() { + // Act + TangemLogger.d("no tag") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Debug, "TangemLoggerTest", "no tag", null, true) + } + } + + @Test + fun `withTag returns a TaggedLogger that uses the supplied tag`() { + // Arrange + val tagged = TangemLogger.withTag("MyFeature") + + // Act + tagged.i("hello") + + // Assert + verify(exactly = 1) { + writer.write(Severity.Info, "MyFeature", "hello", null, true) + } + } + + // endregion + + // region TaggedLogger + + @Test + fun `TaggedLogger dispatches each severity with its tag, throwable and shouldSanitize flag`() { + // Arrange + val tagged = TangemLogger.withTag("Tag") + val throwable = RuntimeException("oops") + + // Act + tagged.v("v") + tagged.d("d") + tagged.i("i") + tagged.w("w") + tagged.e("e", throwable) + tagged.a("a", shouldSanitize = false) + + // Assert + verifyOrder { + writer.write(Severity.Verbose, "Tag", "v", null, true) + writer.write(Severity.Debug, "Tag", "d", null, true) + writer.write(Severity.Info, "Tag", "i", null, true) + writer.write(Severity.Warn, "Tag", "w", null, true) + writer.write(Severity.Error, "Tag", "e", throwable, true) + writer.write(Severity.Assert, "Tag", "a", null, false) + } + } + + @Test + fun `TaggedLogger respects writer isLoggable filtering`() { + // Arrange + every { writer.isLoggable(any(), any()) } returns false + val tagged = TangemLogger.withTag("Filtered") + + // Act + tagged.e("ignored") + + // Assert + verify(exactly = 1) { writer.isLoggable(Severity.Error, "Filtered") } + verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) } + } + + // endregion +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 5bf1853472..5c04c70efa 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -76,7 +76,6 @@ okhttp = "4.9.3" retrofit = "2.11.0" retrofitMoshiConverter = "2.9.0" spongycastleCryptoCore = "1.58.0.0" -kermit = "2.1.0" viewBindingDelegate = "1.5.9" xmlShimmer = "1.1.3" zxingQrCode = "3.5.1" @@ -85,7 +84,6 @@ kotlinDatetime = "0.6.2" arrow = "1.2.4" # 2.0.1 breaks the build reownCore = "1.4.11" reownWeb3 = "1.4.11" -prettyLogger = "2.2.0" okHttp-prettyLogging = "3.1.0" chucker = "4.2.0" mlKit-barcodeScanning = "17.3.0" @@ -282,7 +280,6 @@ spongecastle-core = { module = "com.madgag.spongycastle:core", version.ref = "sp retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-response-type-keeper = { module = "com.squareup.retrofit2:response-type-keeper", version.ref = "retrofit" } retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofitMoshiConverter" } -kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } viewBindingDelegate = { module = "com.github.kirich1409:viewbindingpropertydelegate-noreflection", version.ref = "viewBindingDelegate" } xmlShimmer = { module = "com.github.skydoves:androidveil", version.ref = "xmlShimmer" } zxing-qrCore = { module = "com.google.zxing:core", version.ref = "zxingQrCode" } @@ -292,7 +289,6 @@ arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } arrow-fx = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } reownCore = { module = "com.reown:android-core", version.ref = "reownCore" } reownWeb3 = { module = "com.reown:walletkit", version.ref = "reownWeb3" } -prettyLogger = { module = "com.orhanobut:logger", version.ref = "prettyLogger" } chucker = { module = "com.github.chuckerteam.chucker:library", version.ref = "chucker" } chuckerStub = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" } mlKit-barcodeScanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlKit-barcodeScanning" } From 9ca4d4d1bc701fa00a30bb87808aabd79680d7a9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Apr 2026 13:30:42 +0200 Subject: [PATCH 142/206] Updated on 2026-08-14 --- .../features/tangempay/utils/TangemPayTxHistoryListManager.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt index fa798e16bc..608f354d94 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayTxHistoryListManager.kt @@ -60,9 +60,7 @@ internal class TangemPayTxHistoryListManager( } suspend fun loadMore() { - actionsFlow.emit( - BatchAction.LoadMore(requestParams = TangemPayTxHistoryListConfig(shouldRefresh = false)), - ) + actionsFlow.emit(BatchAction.LoadMore(requestParams = null)) } private fun updateState(batchListState: BatchListState>) { From 970312afd3fa1ca555f6a4eb493196ae934b9757 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Apr 2026 18:02:28 +0500 Subject: [PATCH 143/206] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 10 +++ .../tangem/tap/routing/utils/ChildFactory.kt | 9 +++ .../tap/routing/utils/DeepLinkFactory.kt | 3 + .../tap/routing/utils/DeepLinkFactoryTest.kt | 21 +++++ .../com/tangem/common/routing/AppRoute.kt | 5 ++ .../tangem/common/routing/DeepLinkRoute.kt | 4 + .../common/routing/deeplink/DeeplinkConst.kt | 2 + .../routing/deeplink/DeepLinkBuilderTest.kt | 20 +++++ .../feed/entry/components/FeedEntryRoute.kt | 3 + .../entry/deeplink/NewsDeepLinkHandler.kt | 8 ++ .../components/DefaultFeedEntryComponent.kt | 5 +- .../feed/components/FeedEntryChildFactory.kt | 5 +- .../news/list/DefaultNewsListComponent.kt | 1 + .../deeplink/DefaultNewsDeepLinkHandler.kt | 35 ++++++++ .../feed/deeplink/di/FeedDeepLinkModule.kt | 6 ++ .../feed/model/news/list/NewsListModel.kt | 31 +++++-- .../feed/ui/news/list/NewsListContent.kt | 17 ++++ .../feed/ui/news/list/state/NewsListUM.kt | 5 +- .../DefaultNewsDeepLinkHandlerTest.kt | 80 +++++++++++++++++++ 19 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDeepLinkHandler.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandler.kt create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandlerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 95ce7f4c75..27f8c3c8e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -296,6 +296,16 @@ android:host="onboard-visa" android:scheme="tangem" /> + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index a4d6cdc3b3..7351bc519d 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -700,6 +700,15 @@ internal class ChildFactory @Inject constructor( componentFactory = feedEntryComponentFactory, ) } + is AppRoute.News -> { + createComponentChild( + context = context, + params = FeedEntryRoute.NewsList( + preselectedCategoryId = route.categoryId, + ), + componentFactory = feedEntryComponentFactory, + ) + } } } } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 492d1d23ae..3e9d268dd5 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -9,6 +9,7 @@ import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler @@ -54,6 +55,7 @@ internal class DeepLinkFactory @Inject constructor( private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory, private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, + private val newsDeepLink: NewsDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -159,6 +161,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri) DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri) + DeepLinkRoute.News.host -> newsDeepLink.create(queryParams) else -> { TangemLogger.i( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index 43ba7a2555..7a1c5173b5 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -7,6 +7,7 @@ import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler @@ -87,6 +88,10 @@ class DeepLinkFactoryTest { every { create(any(), any()) } returns mockk() } + private val newsDeepLinkFactory = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + private val marketsTokenExchangesDeepLinkFactory = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() @@ -116,6 +121,7 @@ class DeepLinkFactoryTest { promoDeepLink = promoDeepLinkFactory, onboardVisaDeepLink = onboardVisaDeepLink, newsDetailsDeepLink = newsDeeplink, + newsDeepLink = newsDeepLinkFactory, ) @OptIn(ExperimentalCoroutinesApi::class) @@ -432,6 +438,21 @@ class DeepLinkFactoryTest { verify { onrampDeepLinkFactory.create(eq(testScope), eq(emptyMap())) } } + @Test + fun `handleTangemDeepLinks routes news host to dedicated handler`() = runTest { + every { mockedUri.scheme } returns "tangem" + every { mockedUri.host } returns "news" + every { mockedUri.query } returns null + every { mockedUri.queryParameterNames } returns emptySet() + every { mockedUri.getQueryParameter(any()) } returns null + + deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + + verify { newsDeepLinkFactory.create(eq(emptyMap())) } + } + @Test fun `handleTangemDeepLinks routes to promo handler`() = runTest { every { mockedUri.scheme } returns "tangem" diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index b8c2d6bd0a..3a6eac7c41 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -489,4 +489,9 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class NewsDetails(val newsId: Int) : AppRoute(path = "/news_details/$newsId") + + @Serializable + data class News( + val categoryId: Int? = null, + ) : AppRoute(path = "/news") } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index 359839b059..b9caabe5d0 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -71,6 +71,10 @@ sealed class DeepLinkRoute { data object PayApp : DeepLinkRoute() { override val host: String = "tangem.com" } + + data object News : DeepLinkRoute() { + override val host: String = "news" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index 6fe8031620..8f745a3b32 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -18,4 +18,6 @@ object DeeplinkConst { const val ORDER_KEY = "order" const val INTERVAL_KEY = "interval" const val SECTION_KEY = "section" + const val CATEGORY_ID_KEY = "category_id" + const val NEWS_ID_KEY = "news_id" } \ No newline at end of file diff --git a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt index 596d7cd6c2..bdcb99e48d 100644 --- a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt +++ b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt @@ -88,6 +88,26 @@ internal class DeepLinkBuilderTest { assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://$action?$key1=$value1&$key2=$value2") } + @Test + fun `news host with categoryId produces expected uri`() { + val result = deepLinkBuilder + .setAction("news") + .addQueryParam(DeeplinkConst.CATEGORY_ID_KEY, "5") + .build() + + assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://news?${DeeplinkConst.CATEGORY_ID_KEY}=5") + } + + @Test + fun `news host with newsId produces expected uri`() { + val result = deepLinkBuilder + .setAction("news") + .addQueryParam(DeeplinkConst.NEWS_ID_KEY, "20533") + .build() + + assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://news?${DeeplinkConst.NEWS_ID_KEY}=20533") + } + @Test fun `GIVEN complex deep link WHEN build THEN should construct correct URI`() { // GIVEN diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt index ab97474343..098b29f4da 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt @@ -36,4 +36,7 @@ sealed interface FeedEntryRoute { @Serializable data class NewsDetail(val articleId: Int, val preselectedArticlesId: List) : FeedEntryRoute + + @Serializable + data class NewsList(val preselectedCategoryId: Int? = null) : FeedEntryRoute } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDeepLinkHandler.kt new file mode 100644 index 0000000000..4beed492b5 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/NewsDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.feed.entry.deeplink + +interface NewsDeepLinkHandler { + + interface Factory { + fun create(queryParams: Map): NewsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index a0b3a03088..198cdac711 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -125,7 +125,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( } override fun onOpenAllNews() { - innerRouter.push(FeedEntryChildFactory.Child.NewsList) + innerRouter.push(FeedEntryChildFactory.Child.NewsList()) } override fun onOpenEarnPage() { @@ -263,6 +263,9 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( }, ), ) + is FeedEntryRoute.NewsList -> FeedEntryChildFactory.Child.NewsList( + preselectedCategoryId = entryRoute.preselectedCategoryId, + ) null -> FeedEntryChildFactory.Child.Feed } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index 768445bbd6..fb8c3a7f90 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -53,7 +53,7 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable - data object NewsList : Child + data class NewsList(val preselectedCategoryId: Int? = null) : Child @Serializable @Immutable @@ -110,7 +110,7 @@ internal class FeedEntryChildFactory @Inject constructor( params = child.params, ) } - Child.NewsList -> { + is Child.NewsList -> { DefaultNewsListComponent( appComponentContext = appComponentContext, params = Params( @@ -123,6 +123,7 @@ internal class FeedEntryChildFactory @Inject constructor( ) }, onBackClick = onBackClicked, + preselectedCategoryId = child.preselectedCategoryId, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt index b93cac2f9f..1325a29be6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/news/list/DefaultNewsListComponent.kt @@ -112,5 +112,6 @@ internal class DefaultNewsListComponent( paginationConfig: NewsListConfig?, ) -> Unit, val onBackClick: () -> Unit, + val preselectedCategoryId: Int? = null, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandler.kt new file mode 100644 index 0000000000..66aab9b849 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandler.kt @@ -0,0 +1,35 @@ +package com.tangem.features.feed.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.CATEGORY_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.NEWS_ID_KEY +import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultNewsDeepLinkHandler @AssistedInject constructor( + @Assisted private val queryParams: Map, + private val appRouter: AppRouter, +) : NewsDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + val newsId = queryParams[NEWS_ID_KEY]?.toIntOrNull() + if (newsId != null) { + appRouter.push(AppRoute.NewsDetails(newsId = newsId)) + return + } + + appRouter.push(AppRoute.News(categoryId = queryParams[CATEGORY_ID_KEY]?.toIntOrNull())) + } + + @AssistedFactory + interface Factory : NewsDeepLinkHandler.Factory { + override fun create(queryParams: Map): DefaultNewsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt index d00b30cc79..05f42eb535 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt @@ -1,6 +1,8 @@ package com.tangem.features.feed.deeplink.di +import com.tangem.features.feed.deeplink.DefaultNewsDeepLinkHandler import com.tangem.features.feed.deeplink.DefaultNewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler import dagger.Binds import dagger.Module @@ -17,4 +19,8 @@ internal interface FeedDeepLinkModule { fun bindNewsDetailsDeepLinkHandlerFactory( impl: DefaultNewsDetailsDeepLinkHandler.Factory, ): NewsDetailsDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindNewsDeepLinkHandlerFactory(impl: DefaultNewsDeepLinkHandler.Factory): NewsDeepLinkHandler.Factory } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt index 409a942b93..483ad0271e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/news/list/NewsListModel.kt @@ -6,6 +6,8 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.event.consumedEvent +import com.tangem.core.ui.event.triggeredEvent import com.tangem.domain.news.model.NewsListConfig import com.tangem.domain.news.usecase.GetNewsCategoriesUseCase import com.tangem.domain.news.usecase.GetNewsListBatchFlowUseCase @@ -39,7 +41,7 @@ internal class NewsListModel @Inject constructor( ) : Model() { private val params = paramsContainer.require() - private val selectedCategoryId = MutableStateFlow(null) + private val selectedCategoryId = MutableStateFlow(params.preselectedCategoryId) private val categoriesLoader by lazy { NewsCategoriesLoader( @@ -67,7 +69,7 @@ internal class NewsListModel @Inject constructor( private val _state = MutableStateFlow( NewsListUM( - selectedCategoryId = DEFAULT_ALL_NEWS_CATEGORIES_ID, + selectedCategoryId = params.preselectedCategoryId ?: DEFAULT_ALL_NEWS_CATEGORIES_ID, filters = persistentListOf(), newsListState = NewsListState.Loading, listOfArticles = persistentListOf(), @@ -84,20 +86,38 @@ internal class NewsListModel @Inject constructor( val state = _state.asStateFlow() init { - loadCategories() observeNewsList() - batchFlowManager.reload() + loadCategories() } private fun loadCategories() { modelScope.launch(dispatchers.default) { val filterChips = categoriesLoader.load() + val validIds = filterChips.mapTo(mutableSetOf()) { it.id } + selectedCategoryId.value = selectedCategoryId.value?.takeIf { it in validIds } + val effectiveId = selectedCategoryId.value ?: DEFAULT_ALL_NEWS_CATEGORIES_ID + val scrollIndex = filterChips.indexOfFirst { it.id == effectiveId }.takeIf { it > 0 } _state.update { currentState -> - currentState.copy(filters = filterChips) + currentState.copy( + selectedCategoryId = effectiveId, + filters = filterChips.map { chip -> + chip.copy(isSelected = chip.id == effectiveId) + }.toImmutableList(), + scrollToCategoryEvent = if (scrollIndex != null) { + triggeredEvent(data = scrollIndex, onConsume = ::onScrollToCategoryConsumed) + } else { + currentState.scrollToCategoryEvent + }, + ) } + batchFlowManager.reload() } } + private fun onScrollToCategoryConsumed() { + _state.update { it.copy(scrollToCategoryEvent = consumedEvent()) } + } + private fun observeNewsList() { modelScope.launch(dispatchers.default) { combine( @@ -111,7 +131,6 @@ internal class NewsListModel @Inject constructor( paginationStatus = paginationStatus, onRetryClick = { loadCategories() - batchFlowManager.reload() }, onLoadMore = { batchFlowManager.loadMore() }, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt index ab551d070a..9bc099b815 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/NewsListContent.kt @@ -3,6 +3,7 @@ package com.tangem.features.feed.ui.news.list import androidx.compose.animation.core.EaseOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -20,6 +21,7 @@ import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.components.haze.hazeSourceTangem import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.ds.tabs.TangemTab +import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.* import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM @@ -50,6 +52,9 @@ internal fun NewsListContent(contentPadding: PaddingValues, state: NewsListUM, m internal fun NewsListContentV1(contentPadding: PaddingValues, state: NewsListUM, modifier: Modifier = Modifier) { val background = LocalMainBottomSheetColor.current.value val lazyListState = rememberLazyListState() + val chipsListState = rememberLazyListState() + + ScrollChipsToSelected(state = state, chipsListState = chipsListState) Column( modifier = modifier @@ -58,6 +63,7 @@ internal fun NewsListContentV1(contentPadding: PaddingValues, state: NewsListUM, ) { SpacerH(contentPadding.calculateTopPadding()) LazyRow( + state = chipsListState, contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -84,9 +90,12 @@ internal fun NewsListContentV1(contentPadding: PaddingValues, state: NewsListUM, internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM) { val background = LocalMainBottomSheetColor.current.value val lazyListState = rememberLazyListState() + val chipsListState = rememberLazyListState() var chipsHeight by remember { mutableStateOf(0.dp) } val density = LocalDensity.current + ScrollChipsToSelected(state = state, chipsListState = chipsListState) + Box( modifier = Modifier .fillMaxSize() @@ -103,6 +112,7 @@ internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM) onArticleClick = state.onArticleClick, ) LazyRow( + state = chipsListState, modifier = Modifier .align(Alignment.TopStart) .padding(top = contentPadding.calculateTopPadding(), bottom = TangemTheme.dimens2.x4) @@ -141,6 +151,13 @@ internal fun NewsListContentV2(contentPadding: PaddingValues, state: NewsListUM) } } +@Composable +private fun ScrollChipsToSelected(state: NewsListUM, chipsListState: LazyListState) { + EventEffect(event = state.scrollToCategoryEvent) { index -> + chipsListState.animateScrollToItem(index) + } +} + @Suppress("LongMethod") @Preview(showBackground = true) @Composable diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt index 9d8c0e32b1..b910d4f30d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/list/state/NewsListUM.kt @@ -1,8 +1,10 @@ package com.tangem.features.feed.ui.news.list.state import androidx.compose.runtime.Immutable -import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import com.tangem.core.ui.components.chip.entity.ChipUM +import com.tangem.core.ui.event.StateEvent +import com.tangem.core.ui.event.consumedEvent +import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import kotlinx.collections.immutable.ImmutableList @Immutable @@ -13,6 +15,7 @@ data class NewsListUM( val newsListState: NewsListState, val onArticleClick: (Int) -> Unit, val onBackClick: () -> Unit, + val scrollToCategoryEvent: StateEvent = consumedEvent(), ) @Immutable diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandlerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..d1113f142f --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultNewsDeepLinkHandlerTest.kt @@ -0,0 +1,80 @@ +package com.tangem.features.feed.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.CATEGORY_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.NEWS_ID_KEY +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DefaultNewsDeepLinkHandlerTest { + + private val appRouter: AppRouter = mockk() + + @BeforeEach + fun setUp() { + every { appRouter.push(any(), any()) } just Runs + } + + @Test + fun `no params opens news list with null category`() { + DefaultNewsDeepLinkHandler(queryParams = emptyMap(), appRouter = appRouter) + + verify { appRouter.push(AppRoute.News(categoryId = null), any()) } + } + + @Test + fun `valid categoryId opens news list with that category`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(CATEGORY_ID_KEY to "5"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.News(categoryId = 5), any()) } + } + + @Test + fun `unknown categoryId is forwarded — sanitized in NewsListModel against loaded chips`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(CATEGORY_ID_KEY to "42"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.News(categoryId = 42), any()) } + } + + @Test + fun `non-integer categoryId is silently dropped`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(CATEGORY_ID_KEY to "not-a-number"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.News(categoryId = null), any()) } + } + + @Test + fun `newsId opens news details`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(NEWS_ID_KEY to "20533"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.NewsDetails(newsId = 20533), any()) } + } + + @Test + fun `newsId takes priority over categoryId`() { + DefaultNewsDeepLinkHandler( + queryParams = mapOf(NEWS_ID_KEY to "20533", CATEGORY_ID_KEY to "5"), + appRouter = appRouter, + ) + + verify { appRouter.push(AppRoute.NewsDetails(newsId = 20533), any()) } + } +} \ No newline at end of file From 91b3534504839716fae46d20e0dd95f9cd0700f9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 30 Apr 2026 14:22:44 +0000 Subject: [PATCH 144/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 94aa5668ec..f191596235 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1494" +tangemBlockchainSdk = "develop-1498" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-603" +tangemCardSdk = "develop-614" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From d97b7b9bf770c61a8924675f4d515d3868d623ac Mon Sep 17 00:00:00 2001 From: Tangem Date: Sun, 3 May 2026 23:47:04 -0700 Subject: [PATCH 145/206] Updated on 2026-08-14 --- .../com/tangem/datasource/di/NetworkModule.kt | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 6689718905..b16deb94e1 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -36,11 +36,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) internal object NetworkModule { - private const val TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS = 60L - private const val TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS = 60L - private const val STAKE_KIT_API_TIMEOUT_SECONDS = 60L - - private const val P2P_ETH_POOL_API_TIMEOUT_SECONDS = 60L + private const val TANGEM_LONG_TIMEOUT_SECONDS = 60L @Provides @Singleton @@ -72,10 +68,10 @@ internal object NetworkModule { apiConfigId = ApiConfig.ID.StakeKit, applyTimeoutAnnotations = false, timeouts = Timeouts( - callTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, - connectTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, - readTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, - writeTimeoutSeconds = STAKE_KIT_API_TIMEOUT_SECONDS, + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + writeTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, ), ) } @@ -87,10 +83,10 @@ internal object NetworkModule { apiConfigId = ApiConfig.ID.P2PEthPool, applyTimeoutAnnotations = false, timeouts = Timeouts( - callTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, - connectTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, - readTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, - writeTimeoutSeconds = P2P_ETH_POOL_API_TIMEOUT_SECONDS, + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + writeTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, ), ) } @@ -129,9 +125,9 @@ internal object NetworkModule { apiConfigId = ApiConfig.ID.TangemTech, applyTimeoutAnnotations = false, timeouts = Timeouts( - callTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, - connectTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, - readTimeoutSeconds = TANGEM_TECH_MARKETS_SERVICE_TIMEOUT_SECONDS, + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, ), logsSaving = false, ) @@ -143,6 +139,11 @@ internal object NetworkModule { return retrofitApiBuilder.build( apiConfigId = ApiConfig.ID.TangemPay, applyTimeoutAnnotations = false, + timeouts = Timeouts( + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + ), ) } @@ -152,6 +153,11 @@ internal object NetworkModule { return retrofitApiBuilder.build( apiConfigId = ApiConfig.ID.TangemPay, applyTimeoutAnnotations = false, + timeouts = Timeouts( + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + ), ) } @@ -198,10 +204,10 @@ internal object NetworkModule { apiConfigId = ApiConfig.ID.GaslessTxService, applyTimeoutAnnotations = false, timeouts = Timeouts( - callTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, - connectTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, - readTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, - writeTimeoutSeconds = TANGEM_GASLESS_SERVICE_TIMEOUT_SECONDS, + callTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + connectTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + readTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, + writeTimeoutSeconds = TANGEM_LONG_TIMEOUT_SECONDS, ), ) } From 266e49734ecbfaeac21a68a0e50ebed889c4f38e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 29 Apr 2026 12:43:31 +0400 Subject: [PATCH 146/206] Updated on 2026-08-14 --- .../DefaultDeviceSecurityInfoProvider.kt | 15 +++++-- .../data/swap/DefaultSwapRepositoryV2.kt | 2 +- .../tokens/BalanceFetchingOperations.kt | 5 ++- .../v2/impl/amount/model/SwapAmountModel.kt | 1 + .../model/SwapChooseTokenNetworkModel.kt | 8 ++-- .../confirm/model/SwapTransactionSender.kt | 5 ++- .../sendviaswap/model/SendWithSwapModel.kt | 6 +-- .../feature/swap/DefaultSwapRepository.kt | 2 +- .../feature/swap/domain/SwapInteractorImpl.kt | 11 +++-- .../feature/swap/DefaultSwapComponent.kt | 3 +- .../tangem/feature/swap/model/SwapModel.kt | 41 ++++++++++++++----- 11 files changed, 67 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/core/security/DefaultDeviceSecurityInfoProvider.kt b/app/src/main/java/com/tangem/tap/core/security/DefaultDeviceSecurityInfoProvider.kt index e606ddfde2..586122f7b3 100644 --- a/app/src/main/java/com/tangem/tap/core/security/DefaultDeviceSecurityInfoProvider.kt +++ b/app/src/main/java/com/tangem/tap/core/security/DefaultDeviceSecurityInfoProvider.kt @@ -18,8 +18,9 @@ internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider { val isPatched by lazy { hasSecurityPatch() } val isVulnerable = isAffected && !isPatched TangemLogger.i( - "CVE-2026-20435 check: isAffectedMediaTek=$isAffected, " + + messageString = "CVE-2026-20435 check: isAffectedMediaTek=$isAffected, " + "isPatched=$isPatched, isVulnerable=$isVulnerable", + shouldSanitize = false, ) isVulnerable } @@ -27,7 +28,10 @@ internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider { private fun isAffectedMediaTekDevice(): Boolean { val socModel = resolveMediaTekSocModel() val isAffected = socModel != null && socModel in AFFECTED_MEDIATEK_SOCS - TangemLogger.i("CVE-2026-20435 SoC result: model=$socModel, isAffected=$isAffected") + TangemLogger.i( + messageString = "CVE-2026-20435 SoC result: model=$socModel, isAffected=$isAffected", + shouldSanitize = false, + ) return isAffected } @@ -36,7 +40,10 @@ internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val manufacturer = Build.SOC_MANUFACTURER val model = Build.SOC_MODEL - TangemLogger.i("CVE-2026-20435 Layer 1: SOC_MANUFACTURER=$manufacturer, SOC_MODEL=$model") + TangemLogger.i( + messageString = "CVE-2026-20435 Layer 1: SOC_MANUFACTURER=$manufacturer, SOC_MODEL=$model", + shouldSanitize = false, + ) if (manufacturer.equals("MediaTek", ignoreCase = true)) { extractSocModel(model)?.let { return it } } @@ -44,7 +51,7 @@ internal class DefaultDeviceSecurityInfoProvider : DeviceSecurityInfoProvider { // Layer 2: Build.HARDWARE often contains "mtXXXX" on MediaTek devices (public API) val hardware = Build.HARDWARE - TangemLogger.i("CVE-2026-20435 Layer 2: HARDWARE=$hardware") + TangemLogger.i(messageString = "CVE-2026-20435 Layer 2: HARDWARE=$hardware", shouldSanitize = false) extractSocModel(hardware)?.let { return it } return null diff --git a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt index 82196f8a4e..a888483d9c 100644 --- a/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt +++ b/data/swap/src/main/java/com/tangem/data/swap/DefaultSwapRepositoryV2.kt @@ -470,7 +470,7 @@ internal class DefaultSwapRepositoryV2 @Inject constructor( ).getOrThrow() }, onError = { error -> - TangemLogger.w("Unable to get pairs", error) + TangemLogger.e("Unable to get pairs", error) throw error }, ) diff --git a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/BalanceFetchingOperations.kt b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/BalanceFetchingOperations.kt index 9538efb817..2071908140 100644 --- a/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/BalanceFetchingOperations.kt +++ b/domain/tokens/src/main/kotlin/com/tangem/domain/tokens/BalanceFetchingOperations.kt @@ -116,7 +116,10 @@ class BalanceFetchingOperations( val stakingId = stakingIdFactory.create(userWalletId = userWalletId, cryptoCurrency = currency) if (stakingId.isLeft { it is StakingIdFactory.Error.UnableToGetAddress }) { - TangemLogger.e("Unable to get staking ID for user wallet $userWalletId and currency ${currency.id}") + TangemLogger.e( + messageString = "Unable to get stakingID for user wallet $userWalletId and currency ${currency.id}", + shouldSanitize = false, + ) } stakingId.getOrNull() diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index eb17fb5103..59de970d62 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -620,6 +620,7 @@ internal class SwapAmountModel @Inject constructor( | Primary -> $primaryStatus | Secondary -> $secondaryStatus """.trimIndent(), + shouldSanitize = false, ) showErrorAlert(errorMessage = null) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt index b40d57b863..1567523f5c 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/choosetoken/fromSupported/model/SwapChooseTokenNetworkModel.kt @@ -23,12 +23,12 @@ import com.tangem.features.swap.v2.impl.choosetoken.fromSupported.model.transfor import com.tangem.features.swap.v2.impl.common.SwapUtils.SEND_WITH_SWAP_PROVIDER_TYPES import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @Suppress("LongParameterList") @@ -76,8 +76,8 @@ internal class SwapChooseTokenNetworkModel @Inject constructor( val cryptoCurrencyList = createCryptoCurrencyUseCase( token = params.token, userWalletId = params.userWalletId, - ).getOrElse { - TangemLogger.e("Failed to get crypto currency") + ).getOrElse { throwable -> + TangemLogger.e("Failed to get crypto currency", throwable) swapChooseTokenAlertFactory.getGenericErrorState(params.onDismiss) return@launch } @@ -88,7 +88,7 @@ internal class SwapChooseTokenNetworkModel @Inject constructor( filterProviderTypes = SEND_WITH_SWAP_PROVIDER_TYPES, swapTxType = SwapTxType.SendWithSwap, ).getOrElse { error -> - TangemLogger.e(error.toString()) + TangemLogger.e("Error", error) uiState.update( SwapChooseErrorStateTransformer( tokenName = params.token.name, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt index e35f01fcf3..bfe0696e43 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SwapTransactionSender.kt @@ -67,7 +67,10 @@ internal class SwapTransactionSender @AssistedInject constructor( ExpressProviderType.DEX_BRIDGE, ExpressProviderType.ONRAMP, -> { - TangemLogger.w("Provider $providerType is not supported in Send With Swap") + TangemLogger.i( + messageString = "Provider $providerType is not supported in Send With Swap", + shouldSanitize = false, + ) onExpressError(ExpressError.UnknownError) } } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt index 0ec7b02ecd..e991accc0c 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt @@ -7,6 +7,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -17,7 +18,6 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection -import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.v2.api.entity.FeeSelectorUM @@ -32,9 +32,9 @@ import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.confirm.SendWithSwapConfirmComponent import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM 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 javax.inject.Inject import kotlin.properties.Delegates @@ -159,7 +159,7 @@ internal class SendWithSwapModel @Inject constructor( getPrimaryCurrencyStatusUpdates(params.currency) }, ifLeft = { error -> - TangemLogger.w(error.toString()) + TangemLogger.e(error.toString()) swapAlertFactory.getGenericErrorState( expressError = ExpressError.UnknownError, onFailedTxEmailClick = { diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index 21cc38dfd0..f2c9cfd2af 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -249,7 +249,7 @@ internal class DefaultSwapRepository( ) }, catch = { exception -> - TangemLogger.e("getExchangeStatus error: $exception") + TangemLogger.e("getExchangeStatus error", exception) raise(UnknownError(exception.message)) }, ) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 6079eb8646..35e943bda2 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -208,6 +208,7 @@ internal class SwapInteractorImpl @Inject constructor( |- amountToSwap: $amountToSwap |- selectedFee: $txFeeSealedState """.trimIndent(), + shouldSanitize = false, ) val amountDecimal = toBigDecimalOrNull(amountToSwap) @@ -562,6 +563,7 @@ internal class SwapInteractorImpl @Inject constructor( |- includeFeeInAmount: $includeFeeInAmount |- fee: $fee """.trimIndent(), + shouldSanitize = false, ) val userWallet = fromSwapCurrencyStatus.userWallet @@ -1364,9 +1366,7 @@ internal class SwapInteractorImpl @Inject constructor( reduceBalanceBy: BigDecimal, feeValue: BigDecimal, ): IncludeFeeInAmount { - val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) - - return when (feePaidCurrency) { + return when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { is FeePaidCurrency.Token -> { if (feePaidCurrency.balance > feeValue) { IncludeFeeInAmount.Excluded @@ -1433,8 +1433,7 @@ internal class SwapInteractorImpl @Inject constructor( vararg fees: BigDecimal, ): List { val appCurrency = getSelectedAppCurrencyUseCase.unwrap() - val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus) - val feeCurrencyId: CryptoCurrency.ID = when (feePaidCurrency) { + val feeCurrencyId: CryptoCurrency.ID = when (val feePaidCurrency = getFeePaidCurrency(fromSwapCurrencyStatus)) { is FeePaidCurrency.Token -> feePaidCurrency.tokenId else -> getNativeToken(fromSwapCurrencyStatus).id } @@ -2195,7 +2194,7 @@ internal class SwapInteractorImpl @Inject constructor( return try { SolanaTransactionHelper.removeSignaturesPlaceholders(hash) } catch (e: Exception) { - TangemLogger.e("Failed to format the hash: ${e.message.orEmpty()}") + TangemLogger.e("Failed to format the hash: ${e.message.orEmpty()}", e) hash } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index 417c0e44eb..bf5725be3b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -157,9 +157,10 @@ internal class DefaultSwapComponent @AssistedInject constructor( LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) { if (shouldHideBlock) { TangemLogger.e( - "Dismissing fee selector: " + + messageString = "Dismissing fee selector: " + "shouldHideBlock = $shouldHideBlock, amount = ${dataState.amount}, " + "isInsufficientFunds = ${model.uiState.isInsufficientFunds}", + shouldSanitize = false, ) slotNavigation.dismiss() return@LaunchedEffect diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 89c11aa06a..a1ea402b4b 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -10,6 +10,7 @@ import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter import com.tangem.core.analytics.api.AnalyticsErrorHandler @@ -69,9 +70,6 @@ import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNet import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.analytics.SwapQuotePerformanceTracker -import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload -import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge -import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter import com.tangem.feature.swap.domain.AllowPermissionsHandler @@ -94,9 +92,11 @@ import com.tangem.feature.swap.ui.StateBuilder import com.tangem.feature.swap.utils.formatToUIRepresentation import com.tangem.feature.swap.utils.getContractAddress import com.tangem.features.approval.api.GiveApprovalComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge +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.common.TangemBlogUrlBuilder import com.tangem.features.swap.SwapComponent import com.tangem.utils.Provider import com.tangem.utils.coroutines.* @@ -650,10 +650,16 @@ internal class SwapModel @Inject constructor( userWalletId = fromSwapCurrencyStatus.userWalletId, cryptoCurrencyStatus = fromSwapCurrencyStatus.status, ).onLeft { - TangemLogger.e("Unable to get fee paid crypto currency status for ${fromCryptoCurrency.id}") + TangemLogger.e( + messageString = "Unable to get fee paid crypto currency status for ${fromCryptoCurrency.id}", + shouldSanitize = false, + ) }.onRight { currencyStatus -> if (currencyStatus == null) { - TangemLogger.e("Fee paid crypto currency status is null for ${fromCryptoCurrency.id}") + TangemLogger.e( + messageString = "Fee paid crypto currency status is null for ${fromCryptoCurrency.id}", + shouldSanitize = false, + ) } }.getOrNull() } @@ -722,7 +728,7 @@ internal class SwapModel @Inject constructor( } }, onError = { error -> - TangemLogger.e("Error when loading quotes: $error") + TangemLogger.e("Error when loading quotes", error) performanceTracker.onLoadingFinished(hasError = true) feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true) uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() } @@ -966,6 +972,7 @@ internal class SwapModel @Inject constructor( }.onSuccess { swapTransactionState -> when (swapTransactionState) { is SwapTransactionState.TxSent -> { + TangemLogger.i("onSwapClick: onSuccess: txHash: $swapTransactionState", shouldSanitize = false) if (fee == null) { TangemLogger.e("onSwapClick: onSuccess: fee is null after swap") showAlert(resourceReference(R.string.swapping_fee_estimation_error_text)) @@ -1015,6 +1022,10 @@ internal class SwapModel @Inject constructor( showDemoModeAlert() } is SwapTransactionState.Error -> { + TangemLogger.e( + messageString = "onSwapClick: swap transaction error: $swapTransactionState", + shouldSanitize = false, + ) startLoadingQuotesFromLastState() showTransactionErrorAlert(swapTransactionState) } @@ -1714,7 +1725,11 @@ internal class SwapModel @Inject constructor( val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content if (feeStateUM == null) { - TangemLogger.e("getSelectedFeeState: FeeSelectorUM is not Content: $feeStateUM, returning Legacy state") + TangemLogger.e( + messageString = "getSelectedFeeState: FeeSelectorUM is not Content: $feeStateUM, " + + "returning Legacy state", + shouldSanitize = false, + ) return TxFeeSealedState.Legacy( txFeeState = TxFeeState.Empty, selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL, @@ -1736,7 +1751,10 @@ internal class SwapModel @Inject constructor( val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content if (feeStateUM == null) { - TangemLogger.e("getSelectedFee: FeeSelectorUM is not Content: $feeStateUM, returning null") + TangemLogger.e( + messageString = "getSelectedFee: FeeSelectorUM is not Content: $feeStateUM, returning null", + shouldSanitize = false, + ) return null } @@ -1842,7 +1860,10 @@ internal class SwapModel @Inject constructor( val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) { - TangemLogger.e("loadFee: Quotes not loaded ${dataState.lastLoadedSwapStates[selectedProvider]}") + TangemLogger.e( + messageString = "loadFee: Quotes not loaded ${dataState.lastLoadedSwapStates[selectedProvider]}", + shouldSanitize = false, + ) return Either.Left(GetFeeError.UnknownError) } From 371916a2e9742a2a1a6c9e960e82ae8a266e08c8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 11:10:18 +0400 Subject: [PATCH 147/206] Updated on 2026-08-14 --- .../com/tangem/tap/ApplicationEntryPoint.kt | 10 +-- .../java/com/tangem/tap/TangemApplication.kt | 51 ++---------- .../common/log/TangemAppLoggerInitializer.kt | 28 ------- .../log}/TangemBlockchainSDKLogger.kt | 2 +- .../tap/common/log/TangemCardSDKLogger.kt | 29 +++++-- .../common/log/TangemLoggingInitializer.kt | 80 +++++++++++++++++++ .../tangem/tap/di/data/TangemLoggingModule.kt | 34 ++------ 7 files changed, 115 insertions(+), 119 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt rename app/src/main/java/com/tangem/tap/{data => common/log}/TangemBlockchainSDKLogger.kt (94%) create mode 100644 app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt diff --git a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt index 47d5f23ab4..4c7aa189cb 100644 --- a/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt +++ b/app/src/main/java/com/tangem/tap/ApplicationEntryPoint.kt @@ -1,7 +1,6 @@ package com.tangem.tap import androidx.hilt.work.HiltWorkerFactory -import com.tangem.TangemSdkLogger import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.filter.OneTimeEventFilter import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor @@ -9,13 +8,12 @@ import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.local.config.environment.EnvironmentConfig -import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient -import com.tangem.tap.common.log.TangemAppLoggerInitializer +import com.tangem.tap.common.log.TangemLoggingInitializer import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @@ -36,11 +34,7 @@ interface ApplicationEntryPoint { fun getOneTimeEventFilter(): OneTimeEventFilter - fun getTangemSdkLogger(): TangemSdkLogger - - fun getTangemAppLogger(): TangemAppLoggerInitializer - - fun getAppLogsStore(): AppLogsStore + fun getTangemLoggingInitializer(): TangemLoggingInitializer fun getBlockchainExceptionHandler(): BlockchainExceptionHandler diff --git a/app/src/main/java/com/tangem/tap/TangemApplication.kt b/app/src/main/java/com/tangem/tap/TangemApplication.kt index d66468de37..3684c3af20 100644 --- a/app/src/main/java/com/tangem/tap/TangemApplication.kt +++ b/app/src/main/java/com/tangem/tap/TangemApplication.kt @@ -8,11 +8,7 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import coil.ImageLoader import coil.ImageLoaderFactory -import com.chuckerteam.chucker.api.ChuckerInterceptor -import com.tangem.Log -import com.tangem.TangemSdkLogger import com.tangem.blockchain.common.ExceptionHandler -import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder import com.tangem.core.abtests.manager.ABTestsManager import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.filter.AppsFlyerEventFilter @@ -21,15 +17,10 @@ import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.datasource.api.common.MoshiConverter import com.tangem.datasource.api.common.config.managers.ApiConfigsManager -import com.tangem.datasource.api.common.createNetworkLoggingInterceptor import com.tangem.datasource.local.config.environment.EnvironmentConfig -import com.tangem.datasource.local.logs.AppLogsStore -import com.tangem.datasource.utils.NetworkLogsSaveInterceptor -import com.tangem.datasource.utils.WireMockRedirectInterceptor import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.common.LogConfig import com.tangem.domain.wallets.repository.WalletsRepository -import com.tangem.operations.attestation.api.TangemApiServiceSettings import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler @@ -39,7 +30,7 @@ import com.tangem.tap.common.analytics.handlers.appsflyer.AppsFlyerClient import com.tangem.tap.common.analytics.handlers.customerio.CustomerIoAnalyticsHandler import com.tangem.tap.common.analytics.handlers.firebase.FirebaseAnalyticsHandler import com.tangem.tap.common.images.createCoilImageLoader -import com.tangem.tap.common.log.TangemAppLoggerInitializer +import com.tangem.tap.common.log.TangemLoggingInitializer import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.BuildConfig import dagger.hilt.EntryPoints @@ -72,14 +63,8 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. private val oneTimeEventFilter: OneTimeEventFilter get() = entryPoint.getOneTimeEventFilter() - private val tangemSdkLogger: TangemSdkLogger - get() = entryPoint.getTangemSdkLogger() - - private val tangemAppLoggerInitializer: TangemAppLoggerInitializer - get() = entryPoint.getTangemAppLogger() - - private val appLogsStore: AppLogsStore - get() = entryPoint.getAppLogsStore() + private val tangemLoggingInitializer: TangemLoggingInitializer + get() = entryPoint.getTangemLoggingInitializer() private val blockchainExceptionHandler: BlockchainExceptionHandler get() = entryPoint.getBlockchainExceptionHandler() @@ -142,7 +127,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. * Initialize components that need to be initialized before [super.onCreate] is called */ fun preInit() { - tangemAppLoggerInitializer.initialize() + tangemLoggingInitializer.initAppLogging() registerActivityLifecycleCallbacks(foregroundActivityObserver.callbacks) } @@ -157,7 +142,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. TangemLogger.i(excludedBlockchainsManager.toString()) } - initWithConfigDependency(environmentConfig = environmentConfig) + initAnalytics(application = this, environmentConfig = environmentConfig) abTestsManager.init() @@ -169,26 +154,7 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. ExceptionHandler.append(blockchainExceptionHandler) - if (LogConfig.network.isBlockchainSdkNetworkLogEnabled) { - BlockchainSdkRetrofitBuilder.interceptors = buildList { - if (BuildConfig.MOCK_DATA_SOURCE) { - add(WireMockRedirectInterceptor()) - } - add(createNetworkLoggingInterceptor()) - add(ChuckerInterceptor(this@TangemApplication)) - } - - TangemApiServiceSettings.addInterceptors( - *buildList { - if (BuildConfig.MOCK_DATA_SOURCE) { - add(WireMockRedirectInterceptor()) - } - add(createNetworkLoggingInterceptor()) - add(ChuckerInterceptor(this@TangemApplication)) - add(NetworkLogsSaveInterceptor(appLogsStore)) - }.toTypedArray(), - ) - } + tangemLoggingInitializer.initSdkLogging(this) wcInitializeUseCase.init( projectId = environmentConfig.walletConnectProjectId, @@ -206,11 +172,6 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration. System.loadLibrary("TrustWalletCore") } - private fun initWithConfigDependency(environmentConfig: EnvironmentConfig) { - initAnalytics(this, environmentConfig) - Log.addLogger(logger = tangemSdkLogger) - } - private fun initAnalytics(application: Application, environmentConfig: EnvironmentConfig) { val factory = AnalyticsFactory() factory.addHandlerBuilder(AmplitudeAnalyticsHandler.Builder()) diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt deleted file mode 100644 index b71a74125c..0000000000 --- a/app/src/main/java/com/tangem/tap/common/log/TangemAppLoggerInitializer.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.tap.common.log - -import com.tangem.datasource.local.logs.AppLogsStore -import com.tangem.utils.logging.TangemLogger -import com.tangem.wallet.BuildConfig - -/** - * Tangem app logger - * - * @property appLogsStore app logs store - * -[REDACTED_AUTHOR] - */ -class TangemAppLoggerInitializer( - private val appLogsStore: AppLogsStore, -) { - - fun initialize() { - TangemLogger.setLogWriters( - buildList { - if (BuildConfig.LOG_ENABLED) { - add(LogcatLogWriter()) - } - add(FileLogWriter(appLogsStore)) - }, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt b/app/src/main/java/com/tangem/tap/common/log/TangemBlockchainSDKLogger.kt similarity index 94% rename from app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt rename to app/src/main/java/com/tangem/tap/common/log/TangemBlockchainSDKLogger.kt index fdfcf8789e..c97dc1f59a 100644 --- a/app/src/main/java/com/tangem/tap/data/TangemBlockchainSDKLogger.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemBlockchainSDKLogger.kt @@ -1,4 +1,4 @@ -package com.tangem.tap.data +package com.tangem.tap.common.log import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.datasource.local.logs.AppLogsStore diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt index f938ef17cc..38de07528e 100644 --- a/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemCardSDKLogger.kt @@ -6,27 +6,40 @@ import com.tangem.TangemSdkLogger import com.tangem.datasource.local.logs.AppLogsStore /** - * CardSDK logger implementation + * CardSDK logger implementation. * - * @property levels logging levels - * @property messageFormatter message formatter - * @property appLogsStore app logs store + * @property appLogsStore app logs store * [REDACTED_AUTHOR] */ -@Suppress("UnusedPrivateMember") internal class TangemCardSDKLogger( - private val levels: List, - private val messageFormatter: LogFormat, private val appLogsStore: AppLogsStore, ) : TangemSdkLogger { + private val messageFormatter: LogFormat = LogFormat.StairsFormatter() + override fun log(message: () -> String, level: Log.Level) { - if (!levels.contains(level)) return + if (!LEVELS.contains(level)) return appLogsStore.saveLogMessage( tag = "CardSDK_${level.name}", message = messageFormatter.format(message = message, level = level), ) } + + private companion object { + val LEVELS = listOf( + Log.Level.ApduCommand, + Log.Level.Apdu, + Log.Level.Tlv, + Log.Level.Nfc, + Log.Level.Command, + Log.Level.Session, + Log.Level.View, + Log.Level.Network, + Log.Level.Error, + Log.Level.Biometric, + Log.Level.Info, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt new file mode 100644 index 0000000000..6e9e000506 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt @@ -0,0 +1,80 @@ +package com.tangem.tap.common.log + +import android.app.Application +import com.chuckerteam.chucker.api.ChuckerInterceptor +import com.tangem.Log +import com.tangem.TangemSdkLogger +import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder +import com.tangem.datasource.api.common.createNetworkLoggingInterceptor +import com.tangem.datasource.local.logs.AppLogsStore +import com.tangem.datasource.utils.NetworkLogsSaveInterceptor +import com.tangem.datasource.utils.WireMockRedirectInterceptor +import com.tangem.domain.common.LogConfig +import com.tangem.operations.attestation.api.TangemApiServiceSettings +import com.tangem.utils.logging.TangemLogger +import com.tangem.wallet.BuildConfig + +/** + * Owns all app-startup wiring of the logging subsystem in a single place: + * - [initAppLogging] — registers [TangemLogger] writers (Logcat + file). + * - [initSdkLogging] — registers the Card SDK logger with [Log] and installs OkHttp + * interceptors for the Blockchain SDK and the Tangem API. + * + * @property appLogsStore app logs store used by file-based writer and the network logs save + * interceptor + * @property tangemSdkLogger Card SDK logger registered with [Log.addLogger] + * +[REDACTED_AUTHOR] + */ +class TangemLoggingInitializer( + private val appLogsStore: AppLogsStore, + private val tangemSdkLogger: TangemSdkLogger, +) { + + fun initAppLogging() { + TangemLogger.setLogWriters( + buildList { + if (BuildConfig.LOG_ENABLED) { + add(LogcatLogWriter()) + } + add(FileLogWriter(appLogsStore)) + }, + ) + } + + /** + * Configure logging for the underlying SDKs: + * - register [tangemSdkLogger] with the Card SDK static [Log] facade, + * - install OkHttp interceptors for the Blockchain SDK and Tangem API. + * + * Must be called from `TangemApplication.init()` AFTER `entryPoint.getWalletsRepository()` + * has triggered Hilt singletons construction — in particular `DefaultCardSdkProvider`, + * whose init block registers `AddHeadersInterceptor` in [TangemApiServiceSettings]. + * Calling this method earlier would invert the OkHttp interceptor chain order and + * cause logging interceptors to see requests *without* auth headers. + */ + fun initSdkLogging(application: Application) { + Log.addLogger(logger = tangemSdkLogger) + + if (!LogConfig.network.isBlockchainSdkNetworkLogEnabled) return + + BlockchainSdkRetrofitBuilder.interceptors = buildList { + if (BuildConfig.MOCK_DATA_SOURCE) { + add(WireMockRedirectInterceptor()) + } + add(createNetworkLoggingInterceptor()) + add(ChuckerInterceptor(application)) + } + + TangemApiServiceSettings.addInterceptors( + *buildList { + if (BuildConfig.MOCK_DATA_SOURCE) { + add(WireMockRedirectInterceptor()) + } + add(createNetworkLoggingInterceptor()) + add(ChuckerInterceptor(application)) + add(NetworkLogsSaveInterceptor(appLogsStore)) + }.toTypedArray(), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt index 400f58d8b2..f6dc626675 100644 --- a/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt @@ -1,13 +1,10 @@ package com.tangem.tap.di.data -import com.tangem.Log -import com.tangem.LogFormat -import com.tangem.TangemSdkLogger import com.tangem.blockchain.common.logging.BlockchainSDKLogger import com.tangem.datasource.local.logs.AppLogsStore -import com.tangem.tap.common.log.TangemAppLoggerInitializer +import com.tangem.tap.common.log.TangemBlockchainSDKLogger import com.tangem.tap.common.log.TangemCardSDKLogger -import com.tangem.tap.data.TangemBlockchainSDKLogger +import com.tangem.tap.common.log.TangemLoggingInitializer import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,31 +17,10 @@ internal object TangemLoggingModule { @Provides @Singleton - fun provideAppLoggerInitializer(appLogsStore: AppLogsStore): TangemAppLoggerInitializer { - return TangemAppLoggerInitializer(appLogsStore) - } - - @Provides - @Singleton - fun provideCardSDKLogger(appLogsStore: AppLogsStore): TangemSdkLogger { - val logLevels = listOf( - Log.Level.ApduCommand, - Log.Level.Apdu, - Log.Level.Tlv, - Log.Level.Nfc, - Log.Level.Command, - Log.Level.Session, - Log.Level.View, - Log.Level.Network, - Log.Level.Error, - Log.Level.Biometric, - Log.Level.Info, - ) - - return TangemCardSDKLogger( - levels = logLevels, - messageFormatter = LogFormat.StairsFormatter(), + fun provideLoggingInitializer(appLogsStore: AppLogsStore): TangemLoggingInitializer { + return TangemLoggingInitializer( appLogsStore = appLogsStore, + tangemSdkLogger = TangemCardSDKLogger(appLogsStore), ) } From 68a237a603aa5de788faee27ae7bd02b2927e64f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 09:12:42 +0200 Subject: [PATCH 148/206] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 3 +++ core/res/src/main/res/values-es/strings.xml | 3 +++ core/res/src/main/res/values-fr/strings.xml | 3 +++ core/res/src/main/res/values-it/strings.xml | 3 +++ core/res/src/main/res/values-ja/strings.xml | 3 +++ core/res/src/main/res/values-pt-rBR/strings.xml | 3 +++ core/res/src/main/res/values-ru/strings.xml | 5 ++++- core/res/src/main/res/values-uk-rUA/strings.xml | 3 +++ core/res/src/main/res/values-zh-rCN/strings.xml | 3 +++ core/res/src/main/res/values-zh-rTW/strings.xml | 3 +++ .../java/com/tangem/feature/swap/ui/StateBuilder.kt | 13 +++++++++---- .../com/tangem/feature/swap/ui/SwapSuccessScreen.kt | 4 ++-- 12 files changed, 42 insertions(+), 7 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index c182833574..aa67dcb376 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -150,6 +150,9 @@ Nicht mehr anzeigen Verstanden Guthaben sind ausgeblendet + Swap starten + Tauschen Sie ein Asset gegen ein anderes – in wenigen Schritten + Führen Sie Ihren ersten Swap durch Laut den Blockchain-Entwicklern befinden sich der Kaspa-Token derzeit in der Betaphase. Bleibe dran für Updates! Beta-Phase Die Biometrie ist auf Deinem Gerät deaktiviert, daher kannst Du sie nicht zum Entsperren Deiner Wallets verwenden. Aktiviere die Biometrie in den Geräteeinstellungen, um diese Methode wieder nutzen zu können. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 380471b947..cfebaed199 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -146,6 +146,9 @@ No mostrar de nuevo Entendido Los saldos están ocultos + Iniciar intercambio + Convierte un activo en otro con solo unos toques + Realiza tu primer intercambio Según los desarrolladores de la blockchain, los tokens de Kaspa se encuentran actualmente en fase beta. ¡Estén atentos a las actualizaciones! Modo Beta La biometría está desactivada en su dispositivo, por lo que no puede utilizarla para desbloquear sus billeteras. Active la biometría en los ajustes de su dispositivo para volver a utilizar este método. diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 3114f14a53..b400dc3a12 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -146,6 +146,9 @@ Ne plus afficher Compris Les soldes sont masqués + Lancer l\'échange + Convertissez un actif en un autre en quelques touches + Effectuez votre premier échange Selon les développeurs de la blockchain, les jetons Kaspa sont actuellement en version bêta. Restez à l\'écoute des mises à jour ! Mode bêta La biométrie est désactivée sur votre appareil, vous ne pouvez donc pas l\'utiliser pour déverrouiller vos portefeuilles. Activez la biométrie dans les paramètres de votre appareil pour pouvoir à nouveau utiliser cette méthode. diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 22cdd2be2d..4576e967ff 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -3,6 +3,9 @@ Default Legacy Questa carta non è progettata per funzionare con Tangem + Avvia lo scambio + Converti un asset in un altro con pochi tocchi + Esegui il tuo primo scambio L\'importo inviato e il cambio non può essere inferiore a 1 ADA Accetta Importo diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index bc306eab97..3002f624e7 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -149,6 +149,9 @@ 今後表示しない わかりました 残高は非表示 + スワップを開始 + 数タップで1つの資産を別の資産に交換できます + はじめてのスワップ ブロックチェーン開発者によると、Kaspaトークンは現在ベータ版です。アップデートをお楽しみに! ベータモード この端末では生体認証がオフになっているため、ウォレットの解除に使用できません。再度この方法を使用するには、端末の設定で生体認証を有効にしてください。 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 2aaf2c0bcc..6d21e97a04 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -150,6 +150,9 @@ Não mostrar novamente Entendi Os saldos estão ocultos. + Iniciar swap + Converta um ativo em outro com apenas alguns toques + Faça seu primeiro swap Segundo os desenvolvedores da blockchain, os tokens Kaspa estão atualmente em fase beta. Fique atento para mais novidades! Modo Beta A biometria está desativada no seu dispositivo, portanto, você não pode usá-la para desbloquear suas carteiras. Ative a biometria nas configurações do seu dispositivo para usar esse método novamente. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 91dd3fa436..2eeccb50d5 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -152,6 +152,9 @@ Больше не показывать Понятно Балансы скрыты + Начать обмен + Превратите один актив в другой всего за несколько касаний + Совершите первый обмен Согласно информации от разработчиков сети, токены Kaspa находятся в режиме бета. Следите за обновлениями! Бета режим Биометрия отключена на вашем устройстве, поэтому вы не можете использовать её для разблокировки кошельков. Включите биометрию в настройках устройства, чтобы снова использовать этот способ. @@ -2080,7 +2083,7 @@ Загрузка Сеть Сети - Без лимитно + Безлимитно Кошелек Подключенное приложение Подключенные сети diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index fee0598cb0..ec724bbcf2 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -148,6 +148,9 @@ Більше не показувати Зрозуміло Баланси приховані + Почати обмін + Перетворіть один актив на інший лише кількома дотиками + Здійсніть перший обмін Згідно інформації від розробників мережі, токени Kaspa знаходяться у режимі бета. Слідкуйте за оновленнями! Бета режим Біометрія на вашому пристрої вимкнена, тому ви не можете використовувати її для розблокування гаманців. Увімкніть біометрію в налаштуваннях пристрою, щоб знову використовувати цей метод. diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index b93bb9d6e6..f89300a11d 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -149,6 +149,9 @@ 不要再次显示 明白 余额已隐藏 + 开始兑换 + 完成您的首次兑换 + 完成您的首次兑换 据区块链开发者称,Kaspa代币目前处于测试阶段。敬请关注后续更新! 测试模式 您的设备已关闭生物识别功能,因此无法使用此功能解锁钱包。请在设备设置中启用生物识别功能,即可再次使用此方法。 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index a6205964b9..0059be3867 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -22,6 +22,9 @@ 將錢包保存在應用程序中 啟用以將所有錢包鏈接到 Tangem 應用程序。解鎖應用程序需要生物識別身份驗證。交易簽名需要輕觸您的 Tangem 卡片 APP設置 + 開始兌換 + 完成您的首次兌換 + 完成您的首次兌換 請掃描卡片 請30秒後重試或刷卡 嘗試次數過多 diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 61d9d800cd..db56f3b0a1 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -25,6 +25,7 @@ import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount +import com.tangem.feature.swap.domain.models.domain.RateType import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.model.SwapNotificationsFactory @@ -255,7 +256,7 @@ internal class StateBuilder( inputError = TransactionCardType.InputError.Empty, accountTitleUM = AccountTitleUM.Text( title = resourceReference( - if (isFromCard) R.string.swapping_from_title else R.string.swapping_to_title, + if (isFromCard) R.string.swapping_from_title_v2 else R.string.swapping_to_title, ), ), ), @@ -816,6 +817,7 @@ internal class StateBuilder( val toFiatAmount = getFormattedFiatAmount(toSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount)) val shouldShowStatus = providerState.type == ExchangeProviderType.CEX.providerName + val isFloatRate = dataState.selectedProvider?.rateTypes?.contains(RateType.FLOAT) == true return uiState.copy( successState = SwapSuccessStateHolder( timestamp = swapTransactionState.timestamp, @@ -831,7 +833,10 @@ internal class StateBuilder( fromTitle = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), toTitle = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()), - toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()), + toTokenAmount = stringReference( + swapTransactionState.toAmount.orEmpty() + .let { if (isFloatRate) it.appendApproximateSign() else it }, + ), fromTokenFiatAmount = fromFiatAmount, toTokenFiatAmount = toFiatAmount, fromTokenIconState = iconStateConverter.convert(fromSwapCurrencyStatus.status), @@ -1205,9 +1210,9 @@ internal class StateBuilder( private fun getCardAccountTitle(account: Account?, isFromCard: Boolean): AccountTitleUM { val (prefix, placeholder) = if (isFromCard) { - R.string.common_from to R.string.swapping_from_title + R.string.swapping_from_account_title to R.string.swapping_from_title_v2 } else { - R.string.common_to to R.string.swapping_to_title + R.string.swapping_to_account_title to R.string.swapping_to_title } return if (account != null && isAccountsModeProvider()) { AccountTitleUM.Account( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index 9caf68f4c0..55f583d61e 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -73,7 +73,7 @@ private fun SwapSuccessScreenContent( .padding(horizontal = TangemTheme.dimens.spacing16), ) { TransactionDoneTitle( - title = resourceReference(R.string.common_in_progress), + title = resourceReference(R.string.swap_in_progress), subtitle = resourceReference( R.string.send_date_format, wrappedList( @@ -190,7 +190,7 @@ private fun SwapSuccessScreenButtons( if (shouldShowStatusButton) { SpacerW12() SecondaryButtonIconStart( - text = stringResourceSafe(id = R.string.express_cex_status_button_title), + text = stringResourceSafe(id = R.string.express_provider), iconResId = R.drawable.ic_arrow_top_right_24, onClick = onStatusClick, modifier = Modifier.weight(1f), From de9b16b269031d7c3ab032451ae2384bbdb2606a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 09:12:51 +0200 Subject: [PATCH 149/206] Updated on 2026-08-14 --- .../domain/promo/models/StoryContent.kt | 2 +- .../stories/impl/StoriesSlideConfigs.kt | 20 ++++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/domain/promo/models/src/main/java/com/tangem/domain/promo/models/StoryContent.kt b/domain/promo/models/src/main/java/com/tangem/domain/promo/models/StoryContent.kt index 9cc06cdb94..b351f18f9f 100644 --- a/domain/promo/models/src/main/java/com/tangem/domain/promo/models/StoryContent.kt +++ b/domain/promo/models/src/main/java/com/tangem/domain/promo/models/StoryContent.kt @@ -24,5 +24,5 @@ data class StoryContent( } enum class StoryContentIds(val id: String, val analyticType: String) { - STORY_FIRST_TIME_SWAP(id = "first-time-swap", analyticType = "Swap"), + STORY_FIRST_TIME_SWAP(id = "first-time-swap-v2", analyticType = "Swap"), } \ No newline at end of file diff --git a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt index 1872af1c6d..0c7bf8ca39 100644 --- a/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt +++ b/features/stories/impl/src/main/java/com/tangem/feature/stories/impl/StoriesSlideConfigs.kt @@ -18,24 +18,20 @@ internal object StoriesSlideConfigs { private fun swapSlides(): ImmutableList = persistentListOf( SlideConfig( - com.tangem.core.res.R.string.swap_story_first_title, - com.tangem.core.res.R.string.swap_story_first_subtitle, + com.tangem.core.res.R.string.swap_story_first_title_v2, + com.tangem.core.res.R.string.swap_story_first_subtitle_v2, ), SlideConfig( - com.tangem.core.res.R.string.swap_story_second_title, - com.tangem.core.res.R.string.swap_story_second_subtitle, + com.tangem.core.res.R.string.swap_story_second_title_v2, + com.tangem.core.res.R.string.swap_story_second_subtitle_v2, ), SlideConfig( - com.tangem.core.res.R.string.swap_story_third_title, - com.tangem.core.res.R.string.swap_story_third_subtitle, + com.tangem.core.res.R.string.swap_story_third_title_v2, + com.tangem.core.res.R.string.swap_story_third_subtitle_v2, ), SlideConfig( - com.tangem.core.res.R.string.swap_story_forth_title, - com.tangem.core.res.R.string.swap_story_forth_subtitle, - ), - SlideConfig( - com.tangem.core.res.R.string.swap_story_fifth_title, - com.tangem.core.res.R.string.swap_story_fifth_subtitle, + com.tangem.core.res.R.string.swap_story_forth_title_v2, + com.tangem.core.res.R.string.swap_story_forth_subtitle_v2, ), ) } \ No newline at end of file From 92bf1fa52110e94b4b08afa6f0ee75beee65df35 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 00:13:28 -0700 Subject: [PATCH 150/206] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 35 +++++++++++++++--- core/res/src/main/res/values-es/strings.xml | 36 ++++++++++++++++++- core/res/src/main/res/values-fr/strings.xml | 36 ++++++++++++++++++- core/res/src/main/res/values-it/strings.xml | 36 ++++++++++++++++++- core/res/src/main/res/values-ja/strings.xml | 2 +- .../src/main/res/values-pt-rBR/strings.xml | 20 +++++++++-- core/res/src/main/res/values-ru/strings.xml | 26 +++++++++++++- .../src/main/res/values-uk-rUA/strings.xml | 36 ++++++++++++++++++- .../src/main/res/values-zh-rCN/strings.xml | 7 ++-- .../src/main/res/values-zh-rTW/strings.xml | 8 +++++ core/res/src/main/res/values/strings.xml | 12 ++++--- 11 files changed, 234 insertions(+), 20 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index aa67dcb376..a67d74a936 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1591,6 +1591,8 @@ Entsperren der Karte fehlgeschlagen. Versuchen Sie es später erneut. Ihre Karte ist entsperrt. Abhebung + Dies wurde aufgrund regulatorischer Anforderungen durchgeführt. Auszahlungen sind jedoch weiterhin verfügbar. + Ihre Karte wurde deaktiviert Auf gerooteten Geräten nicht nutzbar. Verfügbares Guthaben KYC vom Hauptbildschirm ausblenden @@ -1627,7 +1629,8 @@ Teile Deine Adresse mit oder zeig den QR-Code. Es wurden technische Probleme festgestellt. Bitte versuche es später erneut oder kontaktiere den Support. Empfangen ist jetzt nicht verfügbar - Es sind nur Buchstaben und Zahlen erlaubt. + Karte neu ausstellen + Es sind nur Buchstaben und Zahlen erlaubt Ungültige Zeichen Aufdecken Details anzeigen @@ -1641,15 +1644,26 @@ Auszahlung derzeit nicht möglich Sie können keinen Tausch oder eine neue Auszahlung starten, bis die aktuelle abgeschlossen ist. Auszahlung läuft + Limit festlegen ab %s + Das Limit konnte nicht festgelegt werden. Bitte versuchen Sie es erneut. + Ändern + Aktuelles Limit + Ihr Tageslimit konnte nicht geladen werden. Bitte versuchen Sie es erneut. + Tageslimit nicht verfügbar + Sie können es jederzeit wieder ändern + Tageslimit ist festgelegt + Tageslimit Einstellungen der Karte PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. - Digitale Karte + Limit von %s bis %s festlegen + Limits festlegen + Digitale Mir ist bewusst, dass ich den Zugriff auf meine Tangem Pay Card und alle darauf befindlichen Guthaben vollständig und ohne Möglichkeit der Wiederherstellung verliere. Kartenausstellung fehlgeschlagen Ein technischer Fehler ist aufgetreten, bitte versuchen Sie es erneut, indem Sie auf die Schaltfläche unten klicken Ein technischer Fehler ist aufgetreten, bitte kontaktieren Sie den Support - Die Funktion wird in Kürze verfügbar sein. + Wird in Kürze verfügbar sein Sie können zusätzliche Karten für Ihr Zahlungskonto ausstellen Holen Sie sich Ihre kostenlose virtuelle Tangem Visa-Karte Tangem Pay erhalten @@ -1686,18 +1700,29 @@ Zahlungskonto Zahlungskonto ist nicht synchronisiert Ungültige PIN: Sequenzen oder Wiederholungen vermeiden + Karte neu ausstellen + Dadurch wird ein neuer Kartendatensatz erstellt. Ihre alten Daten funktionieren nicht mehr. Dieser Vorgang kann nicht rückgängig gemacht werden. + Ersatzgebühr + Informationen zur Ersatzgebühr nicht erreichbar + Ihre Karte ersetzen + Dauert normalerweise bis zu 5 Minuten. In seltenen Fällen bis zu 48 Stunden. + Unzureichendes Guthaben, um die Karte neu auszustellen + Zahlen Sie USDC auf das Zahlungskonto ein, um die Ausstellungsgebühr zu decken + Gebühr kann nicht gedeckt werden + Ihre Karte neu ausstellen? Wir beheben ein technisches Problem. Bitte versuchen Sie es später erneut. Service vorübergehend nicht verfügbar Daten können derzeit nicht angezeigt werden, Kartenzahlungen funktionieren jedoch weiterhin. Satz \nPIN-Code + Karte deaktiviert Sitzung abgelaufen Zugang wiederherstellen Nutzen Sie USDC für alltägliche Zahlungen Tangem Pay ist vorübergehend nicht erreichbar. Tangem Pay - USDC im Polygon-Netzwerk + USDC im Polygon Klicken Sie auf die Schaltfläche unten, um den Zugriff wiederherzustellen - Ihr USDC Polygon On-Chain-Guthaben unterscheidet sich von Ihrem Kartenguthaben und wird innerhalb von 2 Werktagen nach einem Kauf aktualisiert. Gelder aus erstatteten Käufen werden nicht auf Ihr On-Chain-Guthaben zurückerstattet und stehen nicht für Abhebungen zur Verfügung, bleiben aber auf Ihrem Kartenguthaben für Einkäufe verfügbar. + Gelder aus erstatteten Käufen werden nicht auf Ihr On-Chain-Guthaben zurückerstattet und stehen nicht für Abhebungen zur Verfügung, bleiben aber auf Ihrem Kartenguthaben für Einkäufe verfügbar Bitte beachten Sie Ihr PIN-Code Das ist meine Wallet diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index cfebaed199..9fd0d37f78 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1576,7 +1576,10 @@ No se pudo descongelar la tarjeta. Inténtalo de nuevo más tarde. Tu tarjeta está descongelada. Retirada + Esto se hizo debido a requisitos regulatorios. Sin embargo, los retiros siguen estando disponibles. + Su tarjeta ha sido desactivada No se puede usar en un dispositivo rooteado + Saldo Ocultar verificación de la pantalla Agregar fondos Opciones de recarga @@ -1610,10 +1613,14 @@ Comparte tu dirección o muestra el código QR Se detectaron problemas técnicos. Inténtelo de nuevo más tarde o póngase en contacto con el servicio de asistencia. Recepción no disponible ahora + Reemitir tarjeta + Solo se permiten letras y números + Caracteres no válidos Mostrar Mostrar detalles Intercambia cualquier activo de tu portafolio por una tarjeta Detalles de la tarjeta + Por favor, inténtalo de nuevo más tarde Descongelar tarjeta Vuelva a la aplicación si lo olvida. Su código PIN @@ -1621,12 +1628,27 @@ Retiro no disponible ahora No puedes iniciar un intercambio o un nuevo retiro hasta que el actual finalice. Retiro en progreso + Establecer un límite desde %s + No se pudo establecer el límite. Por favor, inténtalo de nuevo. + Cambiar + Límite actual + No se pudo cargar tu límite diario. Por favor, inténtalo de nuevo. + Límite no disponible + Puedes cambiarlo de nuevo quando quieras + El límite está establecido + Límite diario + Configuración de la tarjeta Cambiar código PIN Vuelve a la app si lo olvidas. + Establecer un límite de %s a %s + Establecer límites + Virtuale Entiendo que perderé completamente el acceso a mi tarjeta Tangem Pay y a todos los fondos que contenga sin posibilidad de recuperación Error al emitir la tarjeta Ha ocurrido un error técnico, por favor inténtalo de nuevo haciendo clic en el botón de abajo Ha ocurrido un error técnico, por favor contacta con el soporte + Estará disponible pronto + Podrás emitir tarjetas adicionales para tu cuenta de pago Obtén tu tarjeta virtual Tangem Visa gratuita Obtener Tangem Pay Ir a Soporte @@ -1661,17 +1683,29 @@ Cuenta de pago La cuenta de pago no está sincronizada PIN no válido: evitar secuencias o repeticiones + Reemitir tarjeta + Esto generará un nuevo conjunto de datos de la tarjeta. Tus datos antiguos dejarán de funcionar. No podrás deshacer esta acción. + Comisión + Información sobre la comisión de reposición no disponible + Reemplazo de tu tarjeta + Normalmente tarda hasta 5 minutos. En casos raros, hasta 48 horas. + Fondos insuficientes para reemitir la tarjeta + Deposita USDC en la cuenta de pago para cubrir la comisión de emisión + No se pudo cubrir la comisión + ¿Reemitir tu tarjeta? Estamos solucionando un problema técnico. Por favor, inténtelo de nuevo más tarde. Servicio temporalmente no disponible No es posible mostrar los datos en este momento, pero los pagos con tarjeta siguen funcionando. Establecer \nCódigo PIN + Tarjeta desactivada Sesión expirada Restablecer acceso Usa USDC para pagos cotidianos Tangem Pay no está disponible temporalmente. Tangem Pay + USDC en Polygon Haga clic en el botón de abajo para restaurar el acceso - Tu saldo USDC Polygon on-chain difiere de tu saldo de tarjeta y se actualiza dentro de 2 días hábiles después de una compra. Los fondos de compras reembolsadas no se devolverán a tu saldo on-chain ni estarán disponibles para retiro, pero permanecerán en tu saldo de tarjeta para compras. + Los fondos de compras reembolsadas no se devolverán a tu saldo on-chain Polygon ni estarán disponibles para retiro, pero permanecerán en tu saldo de tarjeta para compras Tenga en cuenta Tu código PIN Esta es mi billetera diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index b400dc3a12..a56d16083d 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1521,7 +1521,10 @@ Échec du dégel de la carte. Réessayez plus tard. Votre carte est dégelée. Retrait + Cela a été fait conformément aux exigences réglementaires. Toutefois, les retraits restent disponibles. + Votre carte a été désactivée Impossible à utiliser sur un appareil rooté + Solde Masquer la vérification de l\'écran Ajouter des fonds Options de recharge @@ -1554,10 +1557,14 @@ Partagez votre adresse ou montrez le QR code Problèmes techniques détectés. Veuillez réessayer plus tard ou contacter le service d\'assistance. Réception indisponible pour le moment + Réémettre la carte + Seules les lettres et les chiffres sont autorisés + Caractères non valides Révéler Afficher les détails Échangez n\'importe quel actif de votre portefeuille contre une carte Détails de la carte + Veuillez réessayer plus tard Dégeler la carte Revenez à l\'application si vous l\'oubliez. Votre code PIN @@ -1565,12 +1572,27 @@ Retrait indisponible pour le moment Vous ne pouvez pas lancer d\'échange ou de nouveau retrait tant que le retrait actuel n\'est pas terminé. Retrait en cours + Définir une limite à partir de %s + Impossible de définir la limite. Veuillez réessayer. + Modifier + Limite actuelle + Impossible de charger votre limite quotidienne. Veuillez réessayer. + Limite indisponible + Vous pouvez le modifier à nouveau quand vous voulez + La limite est définie + Limite quotidienne + Paramètres de la carte Modifier le code PIN Revenez dans l\'application si vous l\'oubliez. + Définir une limite de %s à %s + Définir des limites + Virtuelle Je comprends que je perdrai complètement l\'accès à ma carte Tangem Pay et à tous les fonds qui s\'y trouvent, sans possibilité de récupération. Échec de l\'émission de la carte Une erreur technique s\'est produite, veuillez réessayer en cliquant sur le bouton ci-dessous Une erreur technique s\'est produite, veuillez contacter le support + Sera bientôt disponible + Vous pourrez émettre des cartes supplémentaires pour votre compte de paiement Obtenez votre carte virtuelle Tangem Visa gratuite Obtenir Tangem Pay Contacter le support @@ -1605,17 +1627,29 @@ Compte de paiement Le compte de paiement n\'est pas synchronisé Code PIN invalide : évitez les séquences ou les répétitions + Réémettre la carte + Cette opération génère de nouvelles informations de carte. Vos anciennes informations cesseront de fonctionner. Vous ne pourrez pas annuler cette action. + Frais + Informations sur les frais de remplacement indisponibles + Remplacement de carte + Cela prend généralement jusqu’à 5 minutes. Dans de rares cas, cela peut aller jusqu’à 48 heures. + Fonds insuffisants pour réémettre la carte + Déposez des USDC sur le compte de paiement pour couvrir les frais d’émission + Impossible de couvrir les frais + Réémettre votre carte ? Nous réparons un problème technique. Veuillez réessayer plus tard. Service temporairement indisponible Les données ne peuvent pas être affichées pour le moment, mais les paiements par carte fonctionnent toujours. Définir le \ncode PIN + Carte désactivée Session expirée Restaurer l\'accès Utilisez USDC pour les paiements quotidiens Tangem Pay est temporairement indisponible Tangem Pay + USDC sur Polygon Cliquez sur le bouton ci-dessous pour restaurer l\'accès - Votre solde USDC Polygon on-chain diffère de votre solde de carte et se met à jour dans les 2 jours ouvrables suivant un achat. Les fonds des achats remboursés ne seront pas retournés à votre solde on-chain ni disponibles pour un retrait, mais resteront sur votre solde de carte pour vos achats. + Les fonds des achats remboursés ne seront pas retournés à votre solde sur Polygon ni disponibles pour un retrait, mais resteront sur votre solde de carte pour vos achats Veuillez noter Votre code PIN C\'est mon portefeuille diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 4576e967ff..5291fdefb6 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -102,6 +102,9 @@ Impossibile sbloccare la carta. Riprova più tardi. La tua carta è sbloccata. Prelievo + Questo è stato fatto a causa dei requisiti normativi. Tuttavia, i prelievi sono ancora disponibili. + La tua carta è stata disattivata + Saldo Nascondi verifica dalla schermata Aggiungi fondi Opzioni di ricarica @@ -132,20 +135,39 @@ Aggiungi carta ad Apple Pay Condividi il tuo indirizzo o mostra il QR code Ricezione non disponibile al momento + Riemettere la carta + Sono consentite solo lettere e numeri + Caratteri non validi Rivela Mostra dettagli Scambia qualsiasi asset nel tuo portafoglio con una carta Dettagli carta + Per favore riprova più tardi Sblocca carta Ritiro Ritiro non disponibile ora Non puoi avviare uno swap o un nuovo prelievo finché quello attuale non è terminato Prelievo in corso + Imposta un limite da %s + Impossibile impostare il limite. Riprova. + Modifica + Limite attuale + Non siamo riusciti a caricare il tuo limite giornaliero. Riprova. + Limite non disponibile + Puoi cambiarlo di nuovo quando vuoi + Il limite è impostato + Limite giornaliero + Impostazioni della carta Cambia codice PIN Torna all\'app se lo dimentichi. + Imposta un limite da %s a %s + Imposta limiti + Virtuale Impossibile emettere la carta Si è verificato un errore tecnico, riprova cliccando il pulsante qui sotto Si è verificato un errore tecnico, contatta il supporto + Sarà disponibile a breve + Potrai emettere ulteriori carte per il tuo conto di pagamento Ottieni la tua carta virtuale Tangem Visa gratuita Ottieni Tangem Pay Vai al supporto @@ -177,15 +199,27 @@ Conto di pagamento Il conto di pagamento non è sincronizzato PIN non valido: evitare sequenze o ripetizioni + Riemettere la carta + Questo genererà un nuovo set di dati della carta. I tuoi vecchi dati smetteranno di funzionare. Non puoi annullare questa operazione. + Commissione + Informazioni sulla commissione di sostituzione non disponibili + Sostituzione della tua carta + Di solito richiede fino a 5 minuti. In rari casi, fino a 48 ore. + Fondi insufficienti per riemettere la carta + Deposita USDC sul conto di pagamento per coprire la commissione di emissione + Impossibile coprire la commissione + Vuoi riemettere la tua carta? Stiamo risolvendo un problema tecnico. Riprova più tardi. Servizio temporaneamente non disponibile Al momento non è possibile visualizzare i dati, ma i pagamenti con carta continuano a funzionare. + Carta disattivata Sessione scaduta Usa USDC per i pagamenti quotidiani Tangem Pay è temporaneamente non disponibile Tangem Pay + USDC sulla Polygon Fare clic sul pulsante in basso per ripristinare l\'accesso - Il tuo saldo USDC Polygon on-chain differisce dal saldo della carta e si aggiorna entro 2 giorni lavorativi dopo un acquisto. I fondi degli acquisti rimborsati non verranno restituiti al tuo saldo on-chain né saranno disponibili per il prelievo, ma rimarranno sul saldo della tua carta per gli acquisti. + I fondi degli acquisti rimborsati non verranno restituiti al tuo saldo on-chain Polygon né saranno disponibili per il prelievo, ma rimarranno sul saldo della tua carta per gli acquisti Attenzione Il tuo codice PIN Tangem Twin diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 3002f624e7..cefc401934 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -1654,7 +1654,7 @@ 現在の処理が完了するまでは、スワップや新しい出金を開始することはできません。 出金処理中です %s以上の金額を設定してください - 限度額を設定できませんでした。もう一度お試しください。 + 限度額を設定できませんでした。もう一度お試しください 変更 現在の利用限度額 1日の利用限度額を読み込めませんでした。もう一度お試しください。 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 6d21e97a04..7a017e2a39 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -222,6 +222,7 @@ Contas Ativar Adicionar + Adicionar fundos Adicionar ao portfólio Adicionar token Adicionar tokens @@ -832,6 +833,7 @@ Modo de rendimento Fazer staking é a maneira mais fácil de receber recompensas em suas criptomoedas. %s Ganhe até %s APY + em outra rede ou conta Token adicionado Sobre %s @@ -1179,6 +1181,9 @@ Nenhum token compatível encontrado Este código QR contém parâmetros que não são reconhecidos: %sAlgumas informações de pagamento podem ser perdidas se você continuar. Parâmetros desconhecidos + Cartão de crédito ou conta bancária + Compartilhe seu endereço ou código QR. + Entre seus portfólios Não é necessário memorando %1$s (%2$s) sobre %3$s rede %1$s sobre %2$s rede @@ -1537,6 +1542,7 @@ Ao aprovar, você permite que o contrato inteligente utilize seus tokens em transações futuras. Taxa fixa A rede cobrará uma taxa de aprovação de token para verificar se você está autorizando o uso do seu token para a troca. + Troca em andamento Troque mais tokens com melhores taxas diretamente na sua carteira. Novo provedor de swaps disponível! Procurando algo diferente?\n Experimente pesquisar ou explore outra criptomoeda! @@ -1570,7 +1576,9 @@ Todas as exchanges descentralizadas exigem aprovações para impedir que contratos inteligentes acessem sua carteira sem sua permissão. Por definição, os contratos inteligentes não podem acessar seus tokens a menos que você os aprove. Ao \"desbloquear\" seus tokens, você autoriza o contrato inteligente 1-inch a gastá-los. Os mineradores da rede recebem uma taxa de gás (paga por você) para registrar essa ação no blockchain. Você pode trocar seus tokens após conceder a aprovação. Aprovar Erro na estimativa de custos. Por favor, envie seu feedback para o suporte. + Você envia de Você troca + Você envia Trocar essa quantidade de tokens selecionados causará um impacto significativo no preço e reduzirá seu resultado. Você pode receber um valor significativamente menor devido à baixa liquidez. Tente um valor menor ou outro provedor. Alto impacto nos preços @@ -1579,6 +1587,7 @@ Conceder permissão Trocar Trocar... + Você recebe para Você recebe Escolha o token não disponível @@ -1619,6 +1628,8 @@ Não foi possível desbloquear o cartão. Tente novamente mais tarde. Seu cartão foi desbloqueado. Retirada + Isso foi feito devido a requisitos regulatórios. No entanto, saques ainda estão disponíveis. + Seu cartão foi desativado Não é possível usar em dispositivos com root. Saldo disponível Ocultar KYC da tela principal @@ -1670,6 +1681,8 @@ Retirada indisponível agora Você não pode iniciar uma troca ou um novo saque até que o atual seja concluído. Retirada em andamento + Definir um limite a partir de %s + Não foi possível definir o limite. Tente novamente. Mudar Limite atual Não foi possível carregar seu limite diário. Tente novamente. @@ -1682,7 +1695,7 @@ Volte ao aplicativo se você se esquecer. Defina um limite a partir de %s para %s Definir limites - Cartão digital + Digital Entendo que perderei completamente o acesso ao meu cartão Tangem Pay e a todos os fundos nele contidos, sem possibilidade de recuperação. Falha na emissão do cartão Ocorreu um erro técnico. Tente novamente clicando no botão abaixo. @@ -1720,7 +1733,7 @@ Uma conta de pagamento separada será criada sem divulgar seus endereços e bens. Privacidade incomparável Obtenha seu cartão Tangem Pay gratuito em minutos. - Suporte de pagamento + Suporte de Pay Conta de pagamento A conta de pagamento não está sincronizada. PIN inválido: evite sequências ou repetições. @@ -1738,6 +1751,7 @@ Serviço temporariamente indisponível Não foi possível exibir os detalhes. No entanto, os pagamentos com cartão ainda estão funcionando. Defina o código PIN. + Cartão desativado Sessão expirada Restaurar acesso Use USDC para pagamentos do dia a dia. @@ -1745,7 +1759,7 @@ Tangem Pay USDC na rede Polygon Clique no botão abaixo para restaurar o acesso. - O saldo do seu Polygon em USDC na blockchain é diferente do saldo do seu cartão e é atualizado em até 2 dias úteis após uma compra. Os fundos de compras reembolsadas não serão devolvidos ao seu saldo na blockchain nem estarão disponíveis para saque, mas permanecerão no saldo do seu cartão para compras futuras. + Os fundos de compras reembolsadas não serão devolvidos ao seu saldo na blockchain nem estarão disponíveis para saque, mas permanecerão no saldo do seu cartão para compras futuras Observe Seu código PIN Esta é a minha carteira. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 2eeccb50d5..6d011848ae 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1633,6 +1633,8 @@ Не удалось разморозить карту, попробуйте еще раз Карта разморожена Вывести + Это произошло из-за регуляторных требований. Вывод средств по-прежнему доступен. + Карта была деактивирована Запрещено использовать на root-устройствах Баланс Скрыть KYC с главной @@ -1683,9 +1685,20 @@ Вывод сейчас недоступен Вы не можете начать обмен или новый вывод, пока не завершится текущий. Вывод выполняется + Установить лимит от %s + Не удалось установить лимит. Пожалуйста, попробуйте снова. + Изменить + Текущий лимит + Не удалось загрузить суточный лимит. Пожалуйста, попробуйте снова. + Лимит недоступен + Вы можете поменять его снова в любой момент + Лимит установлен + Суточный лимит Настройки карты Изменить PIN-код Можно посмотреть здесь, если забудете его. + Установить лимит от %s до %s + Установить Виртуальная Я понимаю, что полностью потеряю доступ к своей карте Tangem Pay и ко всем средствам на ней без возможности восстановления. Не удалось выпустить карту @@ -1728,9 +1741,20 @@ Платежный аккаунт Платежный аккаунт не синхронизирован Слабый ПИН: не используйте повторы или последовательности. + Перевыпустить + Будет создана новая карта, старая перестанет работать. Отменить это действие нельзя. + Комиссия + Информация о комиссии недоступна + Перевыпускаем карту + Обычно это занимает до 5 минут. В редких случаях — до 48 часов. + Недостаточно средств для перевыпуска + Внесите USDC на счёт, чтобы покрыть комиссию + Невозможно покрыть комиссию + Перевыпустить карту? Мы устраняем техническую проблему. Пожалуйста, попробуйте позже. Сервис временно недоступен Не можем показать данные карты, но оплаты продолжают работать. + Карта отключена Сессия истекла Обновить сессию Оплачивайте ежедневные покупки в USDC @@ -1738,7 +1762,7 @@ Tangem Pay USDC в сети Polygon Нажмите на кнопку ниже, чтобы восстановить доступ - Баланс ончейн-адреса (USDC Polygon) обновляется в течение 2 рабочих дней после покупки. При возвратах покупок средства не возвращаются на ончейн-баланс и недоступны для вывода, но отображаются на карте и могут быть использованы для покупок. + При возвратах покупок средства не возвращаются на ончейн-баланс Polygon и недоступны для вывода, но отображаются на карте и могут быть использованы для покупок Обратите внимание Ваш PIN-код Это мой кошелек diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index ec724bbcf2..ec58da954a 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1568,7 +1568,10 @@ Не вдалося розморозити картку. Спробуйте пізніше. Картку розморожено. Виведення коштів + Це було зроблено відповідно до регуляторних вимог. Виведення коштів усе ще доступне. + Вашу картку було деактивовано Заборонено використовувати на root-пристроях + Баланс Приховати KYC з головного екрана Поповнити рахунок Варіанти поповнення @@ -1601,10 +1604,14 @@ Поділіться своєю адресою або покажіть QR-код Виявлено технічні проблеми. Будь ласка, спробуйте пізніше або зверніться до служби підтримки. Поповнення наразі недоступне + Перевипустити картку + Дозволені лише літери та цифри + Неприпустимі символи Показати Показати деталі Обміняйте будь-який актив у вашому портфелі на картку Реквізити картки + Будь ласка, спробуйте пізніше Розморозити картку Поверніться до додатка, якщо ви забудете його. Ваш ПІН @@ -1612,12 +1619,27 @@ Вивід наразі недоступний Ви не можете розпочати обмін або новий вивід, поки не завершено поточний. Вивід виконується + Встановити ліміт від %s + Не вдалося встановити ліміт. Будь ласка, спробуйте ще раз. + Змінити + Поточний ліміт + Не вдалося завантажити денний ліміт. Будь ласка, спробуйте ще раз. + Ліміт недоступний + Ви можете змінити це будь-коли + Ліміт встановлено + Денний ліміт + Налаштування картки Змінити PIN-код Можна подивитися тут, якщо забудете його. + Встановити ліміт від %s до %s + Встановити + Віртуальна Я розумію, що повністю втрачу доступ до своєї картки Tangem Pay та всіх коштів на ній без можливості відновлення. Не вдалося випустити картку Технічна помилка, спробуйте ще раз, натиснувши кнопку нижче Технічна помилка, звʼяжіться з підтримки + Невдовзі буде доступно + Ви зможете випускати додаткові картки для свого платіжного рахунку Отримайте безкоштовну віртуальну картку Tangem Visa Отримати Tangem Pay Написати у підтримку @@ -1652,17 +1674,29 @@ Платіжний акаунт Платіжний рахунок не синхронізовано Слабкий ПІН: не використовуйте повторів або послідовностей. + Перевипустити + Це створить новий набір реквізитів картки. Ваші старі реквізити перестануть працювати. Ви не зможете скасувати цю дію. + Комісія + Інформація про комісію за перевипуск недоступна + Перевипуск вашої картки + Зазвичай це займає до 5 хвилин. У поодиноких випадках — до 48 годин. + Недостатньо коштів для перевипуску картки + Поповніть платіжний рахунок в USDC, щоб покрити комісію за випуск + Неможливо покрити комісію + Перевипустити картку? Ми усуваємо технічну проблему. Будь ласка, спробуйте пізніше. Сервіс тимчасово недоступний Не можемо показати дані картки, але оплати продовжують працювати. Встановіть \nPIN-код + Картку деактивовано Сесія закінчилася Відновити доступ Використовуйте USDC для щоденних платежів Tangem Pay тимчасово недоступний Tangem Pay + USDC у Polygon Натисніть кнопку нижче, щоб відновити доступ - Ваш ончейн-баланс USDC Polygon відрізняється від балансу картки та оновлюється протягом 2 робочих днів після покупки. Кошти з повернених покупок не будуть повернуті на ваш ончейн-баланс і не будуть доступні для виведення, але залишаться на балансі картки для покупок. + Кошти з повернених покупок не будуть повернуті на ваш ончейн-баланс Polygon і не будуть доступні для виведення, але залишаться на балансі картки для покупок. Зверніть увагу Ваш PIN-код Це мій гаманець diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index f89300a11d..4cdf3bc127 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1598,6 +1598,8 @@ 卡片解冻失败,请稍后再试。 您的卡片已解冻。 提款 + 这是根据监管要求执行的。不过,提现仍然可用。 + 您的卡已停用 无法在已root的设备上使用 可用余额 从主屏幕隐藏 KYC 页面 @@ -1634,7 +1636,7 @@ 分享您的地址或出示二维码 检测到技术问题。请稍后再试或联系技术支持。 目前无法接收 - 更换卡片 + 重新发行卡片 只允许输入字母和数字。 无效字符 显示 @@ -1714,11 +1716,12 @@ 资金不足,无法更换卡片 将USDC存入支付账户以支付发行费用 无法支付费用 - 更换您的卡? + 要重新发行您的卡片吗? 我们正在修复技术问题,请稍后再试。 服务暂时不可用 无法显示详细信息。但刷卡支付功能仍然可用。 设置 PIN 码 + 卡片已停用 会话已过期 恢复访问权限 使用 USDC 进行日常支付 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index 0059be3867..5cc5b00128 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -345,6 +345,8 @@ 無法解凍卡片。請稍後再試。 您的卡片已解凍。 提現 + 这是根据监管要求执行的。不过,提现仍然可用。 + 您的卡已停用 在主畫面隱藏身份驗證 添加资金 充值选项 @@ -375,6 +377,7 @@ 添加卡片到 Apple Pay 分享您的地址或显示二维码 暫時無法接收 + 重新发行卡片 显示 顯示詳情 將您投資組合中的任何資產兌換成卡片 @@ -384,6 +387,8 @@ 目前无法提现 在当前操作完成之前,您无法启动兑换或新的提款。 提款进行中 + 從 %s 設定限額 + 我们无法设置限额,请稍后再试。 修改PIN码 如果忘记了,请返回应用查看。 无法发行卡片 @@ -419,9 +424,12 @@ 在幾分鐘內獲得免費的 Tangem Pay 卡 付款帳戶 付款帳戶未同步 + 這將產生一組新的卡片資料。您的舊資料將停止使用。此操作無法復原。 + 要重新發行您的卡片嗎? 我们正在修复技术问题。请稍后再试。 服務暫時無法使用 目前無法顯示資料,但卡片支付仍可正常使用。 + 卡片已停用 工作階段已過期 使用 USDC 進行日常支付 Tangem Pay暂时不可用 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 157e6fda53..40a4a3dba0 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -150,9 +150,6 @@ Don\'t show again Got it Balances are hidden - Start Swap - Turn one asset into another in just a few taps - Make your first swap According to the blockchain developers, Kaspa tokens are currently in beta. Stay tuned for updates! Beta Mode Biometrics are turned off on your device, so you can\'t use them to unlock your wallets. Enable biometrics in your device settings to use this method again. @@ -520,6 +517,7 @@ Funds were found on additional addresses. Enable Dynamic Addresses to access them. Funds found on additional addresses Dynamic address + Dynamic addresses management will be available once the pending transaction(s) in network %@ is complete Best opportunities Clear filter The list is temporarily empty as it’s being refreshed. Check back in a moment. @@ -1119,6 +1117,12 @@ This transaction has already been processed. No further action is required. Fetching best rates... Instant + Verification is free and usually takes 1-2 minutes + Tangem won\'t have access to your identity information, you share data directly with regulated provider + Verification unlocks full access to future transactions with this provider + Choose another method + To comply with local regulatory requirements %@ requires identity verification. + Identity verification required by payment provider By using onramp functionality, you agree with provider’s %1$s and %2$s Service is provided by an external provider.\nTangem is not responsible. The purchase amount should be no more than %s @@ -1620,7 +1624,7 @@ Pending Reversed Terms, Fees & Limits - Terms and Limits + Terms and fees The bank rejected this transaction request. This fee goes to cover the cost of handling your transfer. The transaction was partially or fully reversed by the merchant From 0adc20d9aa1f5a7f32c8b27daee6eb10a0b96194 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 00:14:29 -0700 Subject: [PATCH 151/206] Updated on 2026-08-14 --- .../tangempay/details/impl/build.gradle.kts | 11 ++ .../setup/TangemPayCardLimitSetupModel.kt | 19 +-- .../setup/TangemPayCardLimitSetupModelTest.kt | 132 ++++++++++++++++++ 3 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 0aee66703c..84b5260423 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -61,4 +61,15 @@ dependencies { /** Other */ implementation(deps.kotlin.immutable.collections) + + /** Test */ + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.junit5) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) +} + +tasks.withType().configureEach { + useJUnitPlatform() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index 5f3941838b..7672c8911c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -112,6 +112,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( } private fun onAmountChange(newValue: String) { + if (newValue.toBigDecimalOrNull() == null) return uiState.update { state -> state.copy( amountFieldModel = state.amountFieldModel.copy(value = newValue), @@ -121,9 +122,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( } private fun onPresetClick(preset: BigDecimal) { - val amount = uiState.value.amountFieldModel.value.toBigDecimalOrNull() ?: return - val newAmount = if (preset == BigDecimal.ZERO) BigDecimal.ZERO else amount + preset - onAmountChange(newAmount.stripTrailingZeros().toPlainString()) + onAmountChange(preset.stripTrailingZeros().toPlainString()) } private fun onSubmitClick() { @@ -154,7 +153,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( private fun isValid(value: String): Boolean { val amount = value.toBigDecimalOrNull() ?: return false - return amount >= BigDecimal.ZERO + return amount >= MIN_LIMIT } private fun buildSubtitle(maxLimit: BigDecimal?, currency: Currency): TextReference { @@ -163,7 +162,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( id = R.string.tangempay_card_limit_setup_amount_subtitle, formatArgs = WrappedList( listOf( - BigDecimal.ZERO.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, ), ), ) @@ -172,7 +171,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( id = R.string.tangempay_daily_limit_hint, formatArgs = WrappedList( listOf( - BigDecimal.ZERO.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, maxLimit.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, ), ), @@ -181,15 +180,19 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( } private fun buildPresets(currency: Currency) = listOf( - BigDecimal.ZERO, + MIN_LIMIT, BigDecimal("5000"), BigDecimal("10000"), BigDecimal("25000"), ).map { preset -> val label = preset.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) } TangemPayCardLimitSetupUM.LimitPresetUM( - label = if (preset == BigDecimal.ZERO) "0" else "+$label", + label = label, onClick = { onPresetClick(preset) }, ) }.toPersistentList() + + companion object { + private val MIN_LIMIT = BigDecimal.ONE + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt new file mode 100644 index 0000000000..3213e42482 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -0,0 +1,132 @@ +package com.tangem.features.tangempay.limit.setup + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.TangemPayDetailsConfig +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TangemPayCardLimitSetupModelTest { + + private val cardId = "test_card_id" + private val userWalletId = UserWalletId("123") + + private val router: Router = mockk(relaxed = true) + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + private val setLimitUseCase: SetTangemPayCardLimitUseCase = mockk(relaxed = true) + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() + + private val params = TangemPayDetailsContainerComponent.Params( + userWalletId = userWalletId, + config = TangemPayDetailsConfig( + customerId = "customer1", + cardId = cardId, + isPinSet = false, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + cardNumberEnd = "1234", + chainId = 1, + isTangemPayDeactivated = false, + displayName = null, + ), + ) + + private val testCard = TangemPayCard( + id = cardId, + hasPinCode = false, + displayName = null, + limit = null, + isFrozen = false, + lastDigits = "1234", + ) + + private val loadedStatus: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { + every { source } returns StatusSource.ACTUAL + every { cards } returns listOf(testCard) + every { currencyCode } returns "USD" + } + + private val paymentStatus: AccountStatus.Payment = mockk(relaxed = true) { + every { value } returns loadedStatus + } + + private fun createModel(): TangemPayCardLimitSetupModel { + every { paymentAccountStatusSupplier.invoke(userWalletId) } returns flowOf(paymentStatus) + return TangemPayCardLimitSetupModel( + paramsContainer = MutableParamsContainer(params), + dispatchers = TestingCoroutineDispatcherProvider(), + router = router, + paymentAccountStatusSupplier = paymentAccountStatusSupplier, + setTangemPayCardLimitUseCase = setLimitUseCase, + uiMessageSender = uiMessageSender, + ) + } + + @ParameterizedTest + @MethodSource("provideTestCases") + fun `GIVEN amount WHEN changed THEN submit button reflects validity`( + amount: String, + expectedEnabled: Boolean, + ) { + val model = createModel() + + model.uiState.value.amountFieldModel.onValueChange(amount) + + assertThat(model.uiState.value.isSubmitButtonEnabled).isEqualTo(expectedEnabled) + model.onDestroy() + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Presets { + + @Test + fun `WHEN first preset clicked THEN amount set to MIN_LIMIT`() { + val model = createModel() + + model.uiState.value.presets.first().onClick() + + assertThat(model.uiState.value.amountFieldModel.value).isEqualTo("1") + model.onDestroy() + } + + @Test + fun `WHEN last preset clicked THEN amount set to 5000`() { + val model = createModel() + + model.uiState.value.presets.last().onClick() + + assertThat(model.uiState.value.amountFieldModel.value).isEqualTo("25000") + model.onDestroy() + } + } + + private fun provideTestCases() = listOf( + Arguments.of("0", false), + Arguments.of("0.99", false), + Arguments.of("1", true), + Arguments.of("100", true), + Arguments.of("-1", false), + Arguments.of("", false), + Arguments.of("abc", false), + ) +} \ No newline at end of file From dc80c7625cf0a31726a3c1f4f3d258b289ff503d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 11:14:47 +0400 Subject: [PATCH 152/206] Updated on 2026-08-14 --- .../impl/amount/SwapAmountBlockComponent.kt | 9 +- .../v2/impl/amount/model/SwapAmountModel.kt | 11 +- .../SwapChooseProviderComponent.kt | 5 +- .../model/SwapChooseProviderModel.kt | 4 +- .../SwapProviderListItemConverter.kt | 20 ++- .../converter/SwapProviderStateConverter.kt | 16 +- .../common/AmountErrorCurrencyResolver.kt | 13 ++ .../SwapNotificationsComponent.kt | 4 +- .../model/SwapNotificationsModel.kt | 12 +- .../confirm/SendWithSwapConfirmComponent.kt | 2 +- .../confirm/model/SendWithSwapConfirmModel.kt | 2 +- .../SwapProviderListItemConverterTest.kt | 143 ++++++++++++++++++ .../SwapProviderStateConverterTest.kt | 135 +++++++++++++++++ .../common/AmountErrorCurrencyResolverTest.kt | 52 +++++++ 14 files changed, 405 insertions(+), 23 deletions(-) create mode 100644 features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolver.kt create mode 100644 features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverterTest.kt create mode 100644 features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverterTest.kt create mode 100644 features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolverTest.kt diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountBlockComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountBlockComponent.kt index 4df2de3238..e4c1071209 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountBlockComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountBlockComponent.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.settings.usercountry.models.UserCountry +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams.AmountBlockParams import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.SwapAmountModel @@ -82,7 +83,9 @@ internal class SwapAmountBlockComponent( context = childByContext(componentContext), params = SwapChooseProviderComponent.Params( providers = config.providers, - cryptoCurrency = config.cryptoCurrency, + fromCryptoCurrency = config.fromCryptoCurrency, + toCryptoCurrency = config.toCryptoCurrency, + amountType = config.amountType, selectedProvider = config.selectedProvider, userCountry = config.userCountry, callback = model, @@ -93,7 +96,9 @@ internal class SwapAmountBlockComponent( data class SwapChooseProviderConfig( val providers: ImmutableList, - val cryptoCurrency: CryptoCurrency, + val fromCryptoCurrency: CryptoCurrency, + val toCryptoCurrency: CryptoCurrency, + val amountType: SwapAmountType, val selectedProvider: ExpressProvider, val userCountry: UserCountry, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 59de970d62..2bcecfac64 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -375,7 +375,12 @@ internal class SwapAmountModel @Inject constructor( override fun onProviderClick() { val amountUM = uiState.value as? SwapAmountUM.Content ?: return val selectedProvider = amountUM.selectedQuote.provider ?: return - val cryptoCurrency = params.secondaryCryptoCurrency ?: return + val secondaryStatus = amountUM.secondaryCryptoCurrencyStatus ?: return + + val (fromCryptoCurrency, toCryptoCurrency) = amountUM.swapDirection.withSwapDirection( + onDirect = { amountUM.primaryCryptoCurrencyStatus.currency to secondaryStatus.currency }, + onReverse = { secondaryStatus.currency to amountUM.primaryCryptoCurrencyStatus.currency }, + ) analyticsEventHandler.send( SwapAmountAnalyticEvents.ProviderSelectorClicked( @@ -386,7 +391,9 @@ internal class SwapAmountModel @Inject constructor( bottomSheetNavigation.activate( SwapChooseProviderConfig( providers = amountUM.swapQuotes, - cryptoCurrency = cryptoCurrency, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = amountUM.selectedAmountType, selectedProvider = selectedProvider, userCountry = userCountry, ), diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt index c04a88654c..351e5cbad0 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/SwapChooseProviderComponent.kt @@ -11,6 +11,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.settings.usercountry.models.UserCountry +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.chooseprovider.model.SwapChooseProviderModel import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderBottomSheet import com.tangem.features.swap.v2.impl.chooseprovider.ui.SwapChooseProviderContent @@ -49,7 +50,9 @@ internal class SwapChooseProviderComponent( } data class Params( - val cryptoCurrency: CryptoCurrency, + val fromCryptoCurrency: CryptoCurrency, + val toCryptoCurrency: CryptoCurrency, + val amountType: SwapAmountType, val selectedProvider: ExpressProvider, val providers: ImmutableList, val userCountry: UserCountry, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt index dc784cc913..9d550636df 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/SwapChooseProviderModel.kt @@ -29,7 +29,9 @@ internal class SwapChooseProviderModel @Inject constructor( private val swapProviderListItemConverter by lazy(LazyThreadSafetyMode.NONE) { SwapProviderListItemConverter( - cryptoCurrency = params.cryptoCurrency, + fromCryptoCurrency = params.fromCryptoCurrency, + toCryptoCurrency = params.toCryptoCurrency, + amountType = params.amountType, selectedProvider = params.selectedProvider, isNeedApplyFCARestrictions = isNeedApplyFCARestrictions, needBestRateBadge = params.providers.filterIsInstance().isSingleItem().not(), diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt index 7383f568e4..8efc25af94 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverter.kt @@ -12,21 +12,33 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderListItem import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.common.isRestrictedByFCA +import com.tangem.features.swap.v2.impl.common.resolveAmountErrorCurrency import com.tangem.utils.converter.Converter internal class SwapProviderListItemConverter( - private val cryptoCurrency: CryptoCurrency, + private val fromCryptoCurrency: CryptoCurrency, + private val toCryptoCurrency: CryptoCurrency, + private val amountType: SwapAmountType, private val selectedProvider: ExpressProvider, private val isNeedApplyFCARestrictions: Boolean, needBestRateBadge: Boolean, ) : Converter { + private val amountErrorCurrency: CryptoCurrency = resolveAmountErrorCurrency( + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = amountType, + ) + private val providerStateConverter = SwapProviderStateConverter( - cryptoCurrency = cryptoCurrency, + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = amountType, selectedProvider = selectedProvider, isNeedApplyFCARestrictions = isNeedApplyFCARestrictions, isNeedBestRateBadge = needBestRateBadge, @@ -63,7 +75,7 @@ internal class SwapProviderListItemConverter( is ExpressError.AmountError.TooSmallError -> resourceReference( id = R.string.express_provider_min_amount, formatArgs = wrappedList( - error.amount.format { crypto(cryptoCurrency) }, + error.amount.format { crypto(amountErrorCurrency) }, ), ) is ExpressError.AmountError.NotEnoughAllowanceError, @@ -71,7 +83,7 @@ internal class SwapProviderListItemConverter( -> resourceReference( id = R.string.express_provider_max_amount, formatArgs = wrappedList( - error.amount.format { crypto(cryptoCurrency) }, + error.amount.format { crypto(amountErrorCurrency) }, ), ) else -> TextReference.EMPTY diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverter.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverter.kt index a591206770..76db9382b1 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverter.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverter.kt @@ -8,21 +8,31 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderState import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderState.AdditionalBadge import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.common.isRestrictedByFCA +import com.tangem.features.swap.v2.impl.common.resolveAmountErrorCurrency import com.tangem.utils.converter.Converter @Deprecated("Remove with new design") internal class SwapProviderStateConverter( - private val cryptoCurrency: CryptoCurrency, + private val fromCryptoCurrency: CryptoCurrency, + private val toCryptoCurrency: CryptoCurrency, + private val amountType: SwapAmountType, private val selectedProvider: ExpressProvider, private val isNeedBestRateBadge: Boolean, private val isNeedApplyFCARestrictions: Boolean, ) : Converter { + private val amountErrorCurrency: CryptoCurrency = resolveAmountErrorCurrency( + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = amountType, + ) + override fun convert(value: SwapQuoteUM): SwapProviderState { return when (value) { is SwapQuoteUM.Content -> value.convertToContent() @@ -71,7 +81,7 @@ internal class SwapProviderStateConverter( is ExpressError.AmountError.TooSmallError -> resourceReference( id = R.string.express_provider_min_amount, formatArgs = wrappedList( - error.amount.format { crypto(cryptoCurrency) }, + error.amount.format { crypto(amountErrorCurrency) }, ), ) is ExpressError.AmountError.NotEnoughAllowanceError, @@ -79,7 +89,7 @@ internal class SwapProviderStateConverter( -> resourceReference( id = R.string.express_provider_max_amount, formatArgs = wrappedList( - error.amount.format { crypto(cryptoCurrency) }, + error.amount.format { crypto(amountErrorCurrency) }, ), ) else -> TextReference.EMPTY diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolver.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolver.kt new file mode 100644 index 0000000000..3420a9673e --- /dev/null +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolver.kt @@ -0,0 +1,13 @@ +package com.tangem.features.swap.v2.impl.common + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType + +internal fun resolveAmountErrorCurrency( + fromCryptoCurrency: CryptoCurrency, + toCryptoCurrency: CryptoCurrency, + amountType: SwapAmountType?, +): CryptoCurrency = when (amountType) { + SwapAmountType.To -> toCryptoCurrency + SwapAmountType.From, null -> fromCryptoCurrency +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt index c7e62ed4f3..9825830ed6 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/SwapNotificationsComponent.kt @@ -7,10 +7,10 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.domain.express.models.ExpressError import com.tangem.domain.express.models.ExpressProvider -import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapAmountType import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact import java.math.BigDecimal import com.tangem.features.swap.v2.impl.notifications.model.SwapNotificationsModel @@ -54,7 +54,7 @@ internal class SwapNotificationsComponent( val fromCryptoCurrencyStatus: CryptoCurrencyStatus? = null, val priceImpact: PriceImpact? = null, val provider: ExpressProvider? = null, - val rateType: ExpressRateType? = null, + val amountType: SwapAmountType? = null, val shouldIncludeFeeInBalanceCheck: Boolean = false, val feeValue: BigDecimal? = null, ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt index d82b8ace85..c8e796d663 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/notifications/model/SwapNotificationsModel.kt @@ -8,10 +8,10 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.express.models.ExpressError -import com.tangem.domain.express.models.ExpressRateType import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger import com.tangem.features.swap.v2.impl.amount.entity.PriceImpact +import com.tangem.features.swap.v2.impl.common.resolveAmountErrorCurrency import com.tangem.features.swap.v2.impl.notifications.DefaultSwapNotificationsUpdateTrigger import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent.Params.SwapNotificationData @@ -152,11 +152,11 @@ internal class SwapNotificationsModel @Inject constructor( val fromCryptoCurrency = notificationData.fromCryptoCurrency ?: return val toCryptoCurrency = notificationData.toCryptoCurrencyStatus?.currency ?: return - val amountErrorCurrency = if (notificationData.rateType == ExpressRateType.Fixed) { - toCryptoCurrency - } else { - fromCryptoCurrency - } + val amountErrorCurrency = resolveAmountErrorCurrency( + fromCryptoCurrency = fromCryptoCurrency, + toCryptoCurrency = toCryptoCurrency, + amountType = notificationData.amountType, + ) val errorNotification = when (expressError) { is ExpressError.AmountError.TooSmallError -> SwapNotificationUM.Error.MinimalAmountError( diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 387432f1fd..332b0b63f3 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -141,7 +141,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( enteredFromAmount = model.confirmData.enteredFromAmount, fromCryptoCurrencyStatus = model.confirmData.fromCryptoCurrencyStatus, priceImpact = model.confirmData.priceImpact, - rateType = model.confirmData.rateType, + amountType = model.confirmData.amountType, ), ), ) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 5785695c59..7e59abe87e 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -460,7 +460,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( fromCryptoCurrencyStatus = confirmData.fromCryptoCurrencyStatus, priceImpact = confirmData.priceImpact, provider = confirmData.quote?.provider, - rateType = confirmData.rateType, + amountType = confirmData.amountType, shouldIncludeFeeInBalanceCheck = isFixedRate && isAmountSubtractAvailable, feeValue = confirmData.fee?.amount?.value, ), diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverterTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverterTest.kt new file mode 100644 index 0000000000..502f29354c --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderListItemConverterTest.kt @@ -0,0 +1,143 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.model.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.provider.entity.ProviderChooseUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +internal class SwapProviderListItemConverterTest { + + private val provider = ExpressProvider( + providerId = "p1", + name = "Test Provider", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + private val fromCurrency = mockk(relaxed = true).also { + every { it.symbol } returns FROM_SYMBOL + every { it.decimals } returns 18 + } + + private val toCurrency = mockk(relaxed = true).also { + every { it.symbol } returns TO_SYMBOL + every { it.decimals } returns 8 + } + + @Test + fun `GIVEN quote with TooSmallError and amountType To WHEN convert THEN error text uses to symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.To) + val errorQuote = errorQuote(ExpressError.AmountError.TooSmallError(code = 1, amount = BigDecimal("1.5"))) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = TO_SYMBOL, otherSymbol = FROM_SYMBOL) + } + + @Test + fun `GIVEN quote with TooSmallError and amountType From WHEN convert THEN error text uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote(ExpressError.AmountError.TooSmallError(code = 1, amount = BigDecimal("1.5"))) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + @Test + fun `GIVEN quote with TooBigError and amountType To WHEN convert THEN error text uses to symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.To) + val errorQuote = errorQuote(ExpressError.AmountError.TooBigError(code = 2, amount = BigDecimal("999"))) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = TO_SYMBOL, otherSymbol = FROM_SYMBOL) + } + + @Test + fun `GIVEN quote with TooBigError and amountType From WHEN convert THEN error text uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote(ExpressError.AmountError.TooBigError(code = 2, amount = BigDecimal("999"))) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + @Test + fun `GIVEN quote with NotEnoughAllowanceError and amountType From WHEN convert THEN error text uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote( + ExpressError.AmountError.NotEnoughAllowanceError(code = 3, amount = BigDecimal("10")), + ) + + // WHEN + val item = converter.convert(errorQuote) + + // THEN + assertThat(item).isNotNull() + val errorText = (item!!.providerUM.extraUM as ProviderChooseUM.ExtraUM.Error).text + assertSymbolUsed(errorText, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + private fun buildConverter(amountType: SwapAmountType): SwapProviderListItemConverter { + return SwapProviderListItemConverter( + fromCryptoCurrency = fromCurrency, + toCryptoCurrency = toCurrency, + amountType = amountType, + selectedProvider = provider, + isNeedApplyFCARestrictions = false, + needBestRateBadge = false, + ) + } + + private fun errorQuote(error: ExpressError): SwapQuoteUM.Error = SwapQuoteUM.Error( + provider = provider, + expressError = error, + ) + + private fun assertSymbolUsed(text: TextReference, expectedSymbol: String, otherSymbol: String) { + val res = text as TextReference.Res + val formatted = res.formatArgs.first().toString() + assertThat(formatted).contains(expectedSymbol) + assertThat(formatted).doesNotContain(otherSymbol) + } + + private companion object { + const val FROM_SYMBOL = "ETH" + const val TO_SYMBOL = "BTC" + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverterTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverterTest.kt new file mode 100644 index 0000000000..9b01581b84 --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/chooseprovider/model/converter/SwapProviderStateConverterTest.kt @@ -0,0 +1,135 @@ +package com.tangem.features.swap.v2.impl.chooseprovider.model.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.express.models.ExpressError +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType +import com.tangem.features.swap.v2.impl.chooseprovider.entity.SwapProviderState +import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.math.BigDecimal + +@Suppress("DEPRECATION") +internal class SwapProviderStateConverterTest { + + private val provider = ExpressProvider( + providerId = "p1", + name = "Test Provider", + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + private val fromCurrency = mockk(relaxed = true).also { + every { it.symbol } returns FROM_SYMBOL + every { it.decimals } returns 18 + } + + private val toCurrency = mockk(relaxed = true).also { + every { it.symbol } returns TO_SYMBOL + every { it.decimals } returns 8 + } + + @Test + fun `GIVEN error quote TooSmallError and amountType To WHEN convert THEN subtitle uses to symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.To) + val errorQuote = errorQuote(ExpressError.AmountError.TooSmallError(code = 1, amount = BigDecimal("1.5"))) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = TO_SYMBOL, otherSymbol = FROM_SYMBOL) + } + + @Test + fun `GIVEN error quote TooSmallError and amountType From WHEN convert THEN subtitle uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote(ExpressError.AmountError.TooSmallError(code = 1, amount = BigDecimal("1.5"))) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + @Test + fun `GIVEN error quote TooBigError and amountType To WHEN convert THEN subtitle uses to symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.To) + val errorQuote = errorQuote(ExpressError.AmountError.TooBigError(code = 2, amount = BigDecimal("999"))) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = TO_SYMBOL, otherSymbol = FROM_SYMBOL) + } + + @Test + fun `GIVEN error quote TooBigError and amountType From WHEN convert THEN subtitle uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote(ExpressError.AmountError.TooBigError(code = 2, amount = BigDecimal("999"))) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + @Test + fun `GIVEN error quote NotEnoughAllowanceError and amountType From WHEN convert THEN subtitle uses from symbol`() { + // GIVEN + val converter = buildConverter(amountType = SwapAmountType.From) + val errorQuote = errorQuote( + ExpressError.AmountError.NotEnoughAllowanceError(code = 3, amount = BigDecimal("10")), + ) + + // WHEN + val state = converter.convert(errorQuote) + + // THEN + assertSymbolUsed(state, expectedSymbol = FROM_SYMBOL, otherSymbol = TO_SYMBOL) + } + + private fun buildConverter(amountType: SwapAmountType): SwapProviderStateConverter { + return SwapProviderStateConverter( + fromCryptoCurrency = fromCurrency, + toCryptoCurrency = toCurrency, + amountType = amountType, + selectedProvider = provider, + isNeedBestRateBadge = false, + isNeedApplyFCARestrictions = false, + ) + } + + private fun errorQuote(error: ExpressError): SwapQuoteUM.Error = SwapQuoteUM.Error( + provider = provider, + expressError = error, + ) + + private fun assertSymbolUsed(state: SwapProviderState, expectedSymbol: String, otherSymbol: String) { + val content = state as SwapProviderState.Content + val subtitle = content.subtitle as TextReference.Res + val formatted = subtitle.formatArgs.first().toString() + assertThat(formatted).contains(expectedSymbol) + assertThat(formatted).doesNotContain(otherSymbol) + } + + private companion object { + const val FROM_SYMBOL = "ETH" + const val TO_SYMBOL = "BTC" + } +} \ No newline at end of file diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolverTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolverTest.kt new file mode 100644 index 0000000000..88ec6fdb10 --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/common/AmountErrorCurrencyResolverTest.kt @@ -0,0 +1,52 @@ +package com.tangem.features.swap.v2.impl.common + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapAmountType +import io.mockk.mockk +import org.junit.Test + +internal class AmountErrorCurrencyResolverTest { + + private val from = mockk(relaxed = true) + private val to = mockk(relaxed = true) + + @Test + fun `GIVEN amountType From WHEN resolveAmountErrorCurrency THEN returns fromCryptoCurrency`() { + // GIVEN, WHEN + val result = resolveAmountErrorCurrency( + fromCryptoCurrency = from, + toCryptoCurrency = to, + amountType = SwapAmountType.From, + ) + + // THEN + assertThat(result).isSameInstanceAs(from) + } + + @Test + fun `GIVEN amountType To WHEN resolveAmountErrorCurrency THEN returns toCryptoCurrency`() { + // GIVEN, WHEN + val result = resolveAmountErrorCurrency( + fromCryptoCurrency = from, + toCryptoCurrency = to, + amountType = SwapAmountType.To, + ) + + // THEN + assertThat(result).isSameInstanceAs(to) + } + + @Test + fun `GIVEN amountType null WHEN resolveAmountErrorCurrency THEN returns fromCryptoCurrency`() { + // GIVEN, WHEN + val result = resolveAmountErrorCurrency( + fromCryptoCurrency = from, + toCryptoCurrency = to, + amountType = null, + ) + + // THEN + assertThat(result).isSameInstanceAs(from) + } +} \ No newline at end of file From 31048beddb3a384bceeae32b8893a16edce63c1d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 12:19:34 +0500 Subject: [PATCH 153/206] Updated on 2026-08-14 --- .../tangem/feature/swap/DefaultSwapComponent.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index bf5725be3b..bced0cb1ac 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -25,9 +25,9 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.isHotWallet -import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent import com.tangem.feature.swap.model.SwapModel import com.tangem.feature.swap.models.SwapPermissionUM @@ -35,14 +35,14 @@ import com.tangem.feature.swap.router.SwapRoute import com.tangem.feature.swap.ui.SwapScreen import com.tangem.feature.swap.ui.SwapSuccessScreen import com.tangem.features.approval.api.GiveApprovalComponent +import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents import com.tangem.features.swap.SwapComponent -import com.tangem.utils.extensions.isZero +import com.tangem.utils.isNullOrZero import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import java.math.BigDecimal @Suppress("UnusedPrivateMember") internal class DefaultSwapComponent @AssistedInject constructor( @@ -151,7 +151,9 @@ internal class DefaultSwapComponent @AssistedInject constructor( val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } } val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } } val shouldHideBlock by remember { - derivedStateOf { toBigDecimalOrZero(dataState.amount).isZero() || model.uiState.isInsufficientFunds } + derivedStateOf { + dataState.amount?.parseBigDecimalOrNull().isNullOrZero() || model.uiState.isInsufficientFunds + } } LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) { @@ -262,10 +264,6 @@ internal class DefaultSwapComponent @AssistedInject constructor( ) } - private fun toBigDecimalOrZero(bigDecimalString: String?): BigDecimal { - return bigDecimalString?.replace(",", ".")?.toBigDecimalOrNull() ?: BigDecimal.ZERO - } - private fun onChildBack() { val isEmptyStack = childStack.value.backStack.isEmpty() val isSuccess = model.uiState.successState != null From 3fe7585ba8dde6c44f136f67d407b0b8f4366f4a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 12:21:04 +0500 Subject: [PATCH 154/206] Updated on 2026-08-14 --- .../com/tangem/scenarios/BaseScenarios.kt | 10 +++---- .../tangem/screens/MainScreenPageObject.kt | 27 ++++++++++++++++--- .../kotlin/com/tangem/tests/FeedbackTest.kt | 4 --- .../com/tangem/tests/TermsOfServiceTest.kt | 3 +++ 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index beb2b2d14f..22a818f96c 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -20,9 +20,6 @@ fun BaseTestCase.scanCard( productType != null -> MockProvider.setMocks(productType) else -> MockProvider.setMocks(ProductType.Wallet) } - step("Click on 'Accept' button") { - onDisclaimerScreen { acceptButton.clickWithAssertion() } - } step("Click on 'Get started' button") { onStoriesScreen { getStartedButton.clickWithAssertion() } } @@ -60,9 +57,6 @@ fun BaseTestCase.openMainScreen( } fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String) { - step("Click on 'Accept' button") { - onDisclaimerScreen { acceptButton.clickWithAssertion() } - } step("Click on 'Get started' button") { onStoriesScreen { getStartedButton.clickWithAssertion() } } @@ -134,6 +128,10 @@ fun BaseTestCase.synchronizeAddresses( onMainScreen { totalBalanceText.assert(!hasText(DASH_SIGN)) } } } + + step("Expand 'Main account' to reveal tokens") { + onMainScreen { mainAccount().performClick() } + } } fun BaseTestCase.openDeviceSettingsScreen() { diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 79bc25c0f5..abdbaeb2a7 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -219,6 +219,24 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) useUnmergedTree = true } + /** + * Main account header on the main screen. Click to expand/collapse its tokens list. + */ + fun mainAccount(): LazyListItemNode = accountWithName(getResourceString(CoreUiR.string.account_main_account_title)) + + /** + * Account header on the main screen, located by its visible name. Click to expand/collapse its tokens list. + * The account's title text lives on a descendant of the test-tagged node, so we match by descendant. + */ + @OptIn(ExperimentalTestApi::class) + fun accountWithName(name: String): LazyListItemNode { + return lazyList.childWith { + hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) + hasAnyDescendant(withText(name)) + useUnmergedTree = true + } + } + /** * Find token list item with title and address */ @@ -226,7 +244,8 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) fun tokenWithTitleAndAddress(tokenTitle: String): KNode { return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) - hasText(tokenTitle) + hasAnyDescendant(withText(tokenTitle)) + useUnmergedTree = true }.child { hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT) useUnmergedTree = true @@ -237,7 +256,8 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode { return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) - hasText(tokenTitle) + hasAnyDescendant(withText(tokenTitle)) + useUnmergedTree = true }.child { hasTestTag(TokenElementsTestTags.TOKEN_CUSTOM_DERIVATION_ICON) useUnmergedTree = true @@ -277,8 +297,9 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode { return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) - hasText(tokenTitle) + hasAnyDescendant(withText(tokenTitle)) hasLazyListItemPosition(index) + useUnmergedTree = true }.child { hasTestTag(TokenElementsTestTags.TOKEN_TITLE) useUnmergedTree = true diff --git a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt index 6f103dd4b6..b7fa3d9c5b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/FeedbackTest.kt @@ -18,7 +18,6 @@ import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.ThirdPartyAppPageObject import com.tangem.screens.onCreateWalletStartScreen import com.tangem.screens.onDetailsScreen -import com.tangem.screens.onDisclaimerScreen import com.tangem.screens.onFailedTransactionDialog import com.tangem.screens.onMainScreen import com.tangem.screens.onScanWarningDialog @@ -165,9 +164,6 @@ class FeedbackTest : BaseTestCase() { MockProvider.resetEmulateError() } ).run { - step("Click on 'Accept' button") { - onDisclaimerScreen { acceptButton.clickWithAssertion() } - } step("Set scanning error") { MockProvider.setEmulateError(TangemSdkError.TagLost()) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt index 046964ff53..aa60f6632a 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/TermsOfServiceTest.kt @@ -10,6 +10,7 @@ import com.tangem.screens.onStoriesScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -18,6 +19,7 @@ class TermsOfServiceTest : BaseTestCase() { @AllureId("3573") @DisplayName("ToS: success acceptance") @Test + @Ignore("[REDACTED_JIRA]") fun validateTermsOfServiceScreenTest() { setupHooks().run { val tosUrl = "https://tangem.com/tangem_tos.html" @@ -46,6 +48,7 @@ class TermsOfServiceTest : BaseTestCase() { @AllureId("3574") @DisplayName("ToS: accept after app restart") @Test + @Ignore("[REDACTED_JIRA]") fun acceptTermsOfServiceAfterAppRestart() { val packageName = getTargetContext().packageName val tosUrl = "https://tangem.com/tangem_tos.html" From d0bc6087fe26cd428460f5a89a2c3f7dbea06d7d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 09:40:55 +0200 Subject: [PATCH 155/206] Updated on 2026-08-14 --- .../common/ui/markets/MarketListItemV2.kt | 14 +- .../com/tangem/core/ui/components/Fade.kt | 8 +- .../core/ui/components/block/BlockCard.kt | 4 +- .../ui/components/items/DecriptionItem.kt | 10 +- .../tangem/core/ui/ds/TangemPagerIndicator.kt | 21 +- .../core/ui/ds/tabs/TangemSegmentedPicker.kt | 22 +- .../feed/ui/components/MetricsCard.kt | 4 +- .../ui/earn/components/CardFilterBlock.kt | 9 +- .../earn/components/EarnFilterBottomSheet.kt | 2 +- .../EarnFilterByNetworkBottomSheet.kt | 13 +- .../components/EarnFilterByTypeBottomSheet.kt | 16 +- .../feed/ui/earn/components/MostlyUsedCard.kt | 7 +- .../tangem/features/feed/ui/feed/FeedList.kt | 12 + .../feed/ui/feed/components/BlockHeader.kt | 2 +- .../feed/ui/feed/components/DateBlock.kt | 7 +- .../feed/ui/feed/components/MarketsBlock.kt | 8 +- .../feed/ui/feed/components/NewsBlock.kt | 25 ++- .../feed/ui/feed/components/NewsSlider.kt | 4 +- .../feed/components/articles/ArticleCardV2.kt | 105 ++++----- .../feed/components/articles/ArticleHeader.kt | 206 ++++++++---------- .../preview/FeedListPreviewDataProvider.kt | 2 +- .../detailed/MarketsTokenDetailsContent.kt | 8 +- .../components/InformationTextBlock.kt | 9 +- .../market/detailed/components/LinksBlock.kt | 22 +- .../detailed/components/ListedOnBlock.kt | 14 +- .../detailed/components/MetricsCards.kt | 43 ++-- .../detailed/components/SecurityScoreBlock.kt | 73 +++---- .../components/TokenMarketDetailsBody.kt | 78 ++++++- .../ui/news/details/NewsDetailsContent.kt | 8 +- .../news/details/components/ArticleDetail.kt | 69 +++--- .../components/NewsDetailsPlaceholder.kt | 39 +--- .../ui/news/details/components/QuickRecap.kt | 18 +- .../details/components/RelatedNewsItem.kt | 28 +-- .../details/components/RelatedTokensBlock.kt | 120 ++++++++-- 34 files changed, 569 insertions(+), 461 deletions(-) diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt index 9f5a2c1e63..66511d08d6 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/MarketListItemV2.kt @@ -72,14 +72,14 @@ fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modif TangemIcon( tangemIconUM = TangemIconUM.Url(model.iconUrl, fallbackRes = R.drawable.ic_custom_token_44), modifier = Modifier - .size(40.dp) + .size(TangemTheme.dimens2.x10) .layoutId(layoutId = TangemRowLayoutId.HEAD), ) TokenTitle( modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.START_TOP) - .padding(horizontal = TangemTheme.dimens2.x2), + .padding(start = TangemTheme.dimens2.x3), name = model.name, currencySymbol = model.currencySymbol, ) @@ -97,7 +97,7 @@ fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modif TokenSubtitle( modifier = Modifier .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM) - .padding(end = TangemTheme.dimens2.x2, start = TangemTheme.dimens2.x3), + .padding(start = TangemTheme.dimens2.x3), ratingPosition = model.ratingPosition, marketCap = model.marketCap, stakingRate = model.stakingRate, @@ -112,7 +112,7 @@ fun MarketListItemContentV2(model: MarketsListItemUM, modifier: Modifier = Modif if (windowSize.widthAtLeast(WindowSizeType.Small)) { Chart( modifier = Modifier - .padding(start = TangemTheme.dimens2.x2) + .padding(start = TangemTheme.dimens2.x3) .layoutId(layoutId = TangemRowLayoutId.TAIL), chartType = model.chartType, chartRawData = model.chartData, @@ -140,7 +140,7 @@ private fun TokenTitle(name: String, currencySymbol: String, modifier: Modifier modifier = Modifier.alignByBaseline(), text = currencySymbol, color = TangemTheme.colors2.text.neutral.secondary, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, overflow = TextOverflow.Visible, ) @@ -197,7 +197,7 @@ private fun RowScope.TokenRatingPlace(ratingPosition: String?, ratingColor: Colo textAlign = TextAlign.Center, text = ratingPosition ?: MINUS, color = ratingColor, - style = TangemTheme.typography2.captionSemibold12.copy(letterSpacing = 0.sp), + style = TangemTheme.typography2.captionMedium12.copy(letterSpacing = 0.sp), maxLines = 1, ) @@ -216,7 +216,7 @@ private fun RowScope.TokenMarketCapText(text: String, ratingColor: Color, modifi modifier = modifier.alignByBaseline(), text = text, color = ratingColor, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt b/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt index fcc03b10e6..709b5a6625 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/Fade.kt @@ -25,13 +25,17 @@ import dev.chrisbanes.haze.HazeTint * elements and floating button at the bottom of the screen. */ @Composable -fun BottomFade(modifier: Modifier = Modifier, backgroundColor: Color = TangemTheme.colors.background.secondary) { +fun BottomFade( + modifier: Modifier = Modifier, + backgroundColor: Color = TangemTheme.colors.background.secondary, + height: Dp = 100.dp, +) { val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() } Box( modifier = modifier .fillMaxWidth() - .height(TangemTheme.dimens.size100 + bottomBarHeight) + .height(height + bottomBarHeight) .background( brush = Brush.verticalGradient( colors = listOf( diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockCard.kt b/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockCard.kt index 49752f2700..6d19073670 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockCard.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/block/BlockCard.kt @@ -6,12 +6,14 @@ import androidx.compose.material3.CardColors import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape import com.tangem.core.ui.res.TangemTheme @Composable fun BlockCard( modifier: Modifier = Modifier, enabled: Boolean = true, + shape: Shape = TangemTheme.shapes.roundedCornersXMedium, colors: CardColors = TangemBlockCardColors, onClick: () -> Unit = {}, content: @Composable ColumnScope.() -> Unit = {}, @@ -19,7 +21,7 @@ fun BlockCard( Card( modifier = modifier, onClick = onClick, - shape = TangemTheme.shapes.roundedCornersXMedium, + shape = shape, colors = colors, enabled = enabled, content = content, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt index 2841c1c57e..a3dd844b63 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/items/DecriptionItem.kt @@ -127,13 +127,13 @@ private fun DescriptionItemV2( onClick = onReadMoreClick, ), text = text, - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, ) } else { Text( modifier = modifier, text = description.resolveReference(), - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors2.text.neutral.tertiary, ) } @@ -179,17 +179,17 @@ private fun DescriptionPlaceholderV2(modifier: Modifier = Modifier) { ) { TextShimmer( modifier = Modifier.fillMaxWidth(), - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, radius = TangemTheme.dimens2.x25, ) TextShimmer( modifier = Modifier.fillMaxWidth(), - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, radius = TangemTheme.dimens2.x25, ) TextShimmer( modifier = Modifier.fillMaxWidth(fraction = 0.8f), - style = TangemTheme.typography2.bodyRegular15, + style = TangemTheme.typography2.bodyMedium16, radius = TangemTheme.dimens2.x25, ) } diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt index 05fcdc6c3a..e86793d0d1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/TangemPagerIndicator.kt @@ -14,20 +14,20 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.* +import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -63,6 +63,7 @@ private val SMALL_DOT_SIZE = DpSize(4.dp, 4.dp) fun TangemPagerIndicator( pagerState: PagerState, modifier: Modifier = Modifier, + hazeState: HazeState = rememberHazeState(), colors: PagerIndicatorColors = TangemPagerIndicatorColors, ) { val totalPages = pagerState.pageCount @@ -84,10 +85,12 @@ fun TangemPagerIndicator( .width(getSize(totalPages)) .conditionalCompose(colors.overlay != null) { colors.overlay?.let { overlay -> - background( - color = overlay, - shape = CircleShape, - ) + this + .clip(CircleShape) + .hazeEffectTangem(hazeState) { + this.blurRadius = 16.dp + backgroundColor = overlay + } } ?: this } .padding(TangemTheme.dimens2.x3), diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt index b685eab6d8..7483bad306 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt @@ -1,8 +1,10 @@ package com.tangem.core.ui.ds.tabs import android.content.res.Configuration +import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -86,7 +88,7 @@ fun TangemSegmentedPicker( val density = LocalDensity.current val itemsWidths = remember { mutableStateListOf(*Array(items.size) { 0.dp }) } - val selectedIndex = remember { mutableStateOf(items.indexOfFirstOrNull { it == initialSelectedItem } ?: 0) } + val selectedIndex = remember { mutableIntStateOf(items.indexOfFirstOrNull { it == initialSelectedItem } ?: 0) } val segmentHeight = remember { mutableStateOf(0.dp) } val shape = RoundedCornerShape(TangemTheme.dimens2.x25) @@ -105,7 +107,7 @@ fun TangemSegmentedPicker( ) { SegmentSelection( itemsWidths = itemsWidths, - selectedIndex = selectedIndex.value, + selectedIndex = selectedIndex.intValue, segmentHeight = segmentHeight.value, ) Row(verticalAlignment = Alignment.CenterVertically) { @@ -151,18 +153,30 @@ fun TangemSegmentedPicker( @Composable private fun SegmentSelection(itemsWidths: SnapshotStateList, selectedIndex: Int, segmentHeight: Dp) { + var hasInitiallyMeasured by remember { mutableStateOf(false) } + + val animationSpec: AnimationSpec = if (hasInitiallyMeasured) { + tween(durationMillis = 300) + } else { + snap() + } + val indicatorOffset by animateDpAsState( targetValue = itemsWidths.take(selectedIndex).fold(0.dp, Dp::plus), - animationSpec = tween(durationMillis = 300), + animationSpec = animationSpec, label = "indicatorOffset", ) val indicatorWidth by animateDpAsState( targetValue = itemsWidths[selectedIndex], - animationSpec = tween(durationMillis = 300), + animationSpec = animationSpec, label = "indicatorWidth", ) + LaunchedEffect(itemsWidths[selectedIndex] > 0.dp) { + if (itemsWidths[selectedIndex] > 0.dp) hasInitiallyMeasured = true + } + Box( modifier = Modifier .offset { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt index ed671b018a..abef06881d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/MetricsCard.kt @@ -30,7 +30,7 @@ internal fun MetricsCard( modifier = modifier .background( color = cardColor, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), + shape = RoundedCornerShape(TangemTheme.dimens2.x6), ) .conditional( condition = onClick != null, @@ -67,7 +67,7 @@ private fun MetricsCardPreview() { content = { Text( text = "Market cap", - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.tertiary, ) }, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt index 56cb167e10..b4b2087cad 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/CardFilterBlock.kt @@ -1,14 +1,12 @@ package com.tangem.features.feed.ui.earn.components import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.tangem.core.ui.res.TangemTheme @Composable @@ -17,12 +15,7 @@ internal fun CardFilterBlock(modifier: Modifier = Modifier, content: @Composable modifier = modifier .fillMaxWidth() .background( - color = TangemTheme.colors2.surface.level2, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), - ) - .border( - width = 1.dp, - color = TangemTheme.colors2.border.neutral.primary, + color = TangemTheme.colors2.surface.level3, shape = RoundedCornerShape(TangemTheme.dimens2.x5), ), content = content, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt index dff1428685..d89e5a05a2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterBottomSheet.kt @@ -28,7 +28,7 @@ internal inline fun EarnFilterBotto TangemBottomSheet( config = config, type = TangemBottomSheetType.Modal, - containerColor = TangemTheme.colors2.surface.level3, + containerColor = TangemTheme.colors2.surface.level2, title = { TangemTopBar( title = resourceReference(R.string.earn_filter_by), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt index befac566af..50ffe3073a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByNetworkBottomSheet.kt @@ -151,7 +151,16 @@ private fun NetworksTypesBlock( addDefaultPadding = false, ) .clickable { onOptionClick(item) }, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp), + contentPadding = PaddingValues( + start = TangemTheme.dimens2.x3, + end = TangemTheme.dimens2.x3, + top = if (index == 0) 18.dp else TangemTheme.dimens2.x3, + bottom = if (index == allMyNetworks.lastIndex) { + 18.dp + } else { + TangemTheme.dimens2.x3 + }, + ), ) { Text( modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), @@ -190,7 +199,7 @@ private fun SpecificNetworksBlock( .padding(horizontal = 16.dp) .padding(top = 16.dp, bottom = 8.dp), text = stringResourceSafe(id = R.string.earn_filter_networks), - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, color = TangemTheme.colors2.text.neutral.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt index 9c8a7d4381..6b6da63857 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/EarnFilterByTypeBottomSheet.kt @@ -117,13 +117,15 @@ private fun ContentV2(content: EarnFilterByTypeBottomSheetContentUM) { overflow = TextOverflow.Ellipsis, ) - TangemCheckbox( - modifier = Modifier - .padding(start = 8.dp) - .layoutId(layoutId = TangemRowLayoutId.TAIL), - isChecked = type == content.selectedOption, - onCheckedChange = { content.onOptionClick(type) }, - ) + if (type == content.selectedOption) { + TangemCheckbox( + modifier = Modifier + .padding(start = 8.dp) + .layoutId(layoutId = TangemRowLayoutId.TAIL), + isChecked = true, + onCheckedChange = { content.onOptionClick(type) }, + ) + } } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt index 1acfb25681..e1e90b4328 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/components/MostlyUsedCard.kt @@ -3,6 +3,7 @@ package com.tangem.features.feed.ui.earn.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -46,7 +47,7 @@ private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier OpportunitiesBG( modifier = modifier .width(178.dp) - .clip(TangemTheme.shapes.roundedCornersXMedium) + .clip(RoundedCornerShape(TangemTheme.dimens2.x6)) .clickable(onClick = onClick), icon = TangemIconUM.Currency(item.currencyIconState), ) { @@ -76,7 +77,7 @@ private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier Text( text = item.symbol.resolveReference(), color = TangemTheme.colors.text.tertiary, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, ) } @@ -86,7 +87,7 @@ private fun MostlyUsedCardV2(item: EarnListItemUM, onClick: () -> Unit, modifier Text( text = item.earnValue.resolveReference(), color = TangemTheme.colors2.text.status.positive, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index e69bccddd0..3f80f6214c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.platform.testTag @@ -21,6 +22,7 @@ import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.test.BaseSearchBarTestTags.SEARCH_BAR import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.components.FeedSearchBar @@ -159,4 +161,14 @@ private fun FeedListPreview() { TangemThemePreview { FeedList(state = createFeedPreviewState(), contentPadding = PaddingValues()) } +} + +@Preview(showBackground = true, heightDp = 1500) +@Composable +private fun FeedListPreviewV2() { + TangemThemePreviewRedesign { + CompositionLocalProvider(LocalRedesignEnabled provides true) { + FeedList(state = createFeedPreviewState(), contentPadding = PaddingValues()) + } + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt index 32a6229d2e..5c9fc72db8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/BlockHeader.kt @@ -32,7 +32,7 @@ internal fun ColumnScope.Header( ) { val isRedesignEnabled = LocalRedesignEnabled.current if (isRedesignEnabled) { - SpacerH(20.dp) + SpacerH(16.dp) } AnimatedContent(isLoading) { animatedState -> Row( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/DateBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/DateBlock.kt index b38b335ae1..2c3ffe2bd8 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/DateBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/DateBlock.kt @@ -48,18 +48,17 @@ private fun DateBlockV2(currentDate: String) { Text( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp), + .padding(horizontal = TangemTheme.dimens2.x6), text = stringResourceSafe(R.string.feed_market_and_news), - style = TangemTheme.typography2.headingRegular28, + style = TangemTheme.typography2.headingSemibold28, color = TangemTheme.colors2.text.neutral.primary, ) Text( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp), + .padding(horizontal = TangemTheme.dimens2.x6), text = currentDate, style = TangemTheme.typography2.headingRegular28, color = TangemTheme.colors2.text.neutral.tertiary, ) - SpacerH(TangemTheme.dimens2.x6) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/MarketsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/MarketsBlock.kt index daf0b624ae..9bf3a68523 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/MarketsBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/MarketsBlock.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items 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.getValue @@ -117,7 +118,7 @@ internal fun ColumnScope.MarketPulseBlock(marketChartConfig: MarketChartConfig, ) LazyRow( - modifier = Modifier.padding(vertical = if (isRedesignEnabled) 12.dp else 4.dp), + modifier = Modifier.padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, contentPadding = if (isRedesignEnabled) { PaddingValues(horizontal = 16.dp, vertical = 6.dp) @@ -180,6 +181,11 @@ private fun Charts( TangemTheme.colors.background.action }, ), + shape = if (isRedesignEnabled) { + RoundedCornerShape(TangemTheme.dimens2.x6) + } else { + TangemTheme.shapes.roundedCornersXMedium + }, ) { Column(modifier = Modifier.fillMaxWidth()) { when (marketChart) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt index 443eff2ac5..84414ebab7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsBlock.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -37,12 +39,14 @@ import com.tangem.features.feed.ui.feed.state.* internal const val FOURTH_ITEM_INDEX = 3 private const val GRADIENT_START = 0f private const val GRADIENT_END = 0.5f -private const val LINEAR_GRADIENT_FIRST_PART_V2 = 0xFFA3A0FF -private const val LINEAR_GRADIENT_SECOND_PART_V2 = 0xFFF79DFF +private const val LINEAR_GRADIENT_FIRST_PART_V2 = 0xFF7B78FF +private const val LINEAR_GRADIENT_SECOND_PART_V2 = 0xFFC56BCD private const val LINEAR_GRADIENT_FIRST_PART_V1 = 0xFF635EEC private const val LINEAR_GRADIENT_SECOND_PART_V1 = 0xFFE05AED +private const val MAGIC_ICON_COLOR = 0xFF7D78FF + @Composable internal fun NewsBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, trendingArticle: ArticleConfigUM?) { AnimatedContent(news.newsUMState) { newsUMState -> @@ -102,10 +106,19 @@ private fun NewsContentBlock(feedListCallbacks: FeedListCallbacks, news: NewsUM, SpacerW(4.dp) - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_stars_20), - contentDescription = null, - ) + if (isRedesignEnabled) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x6), + tint = Color(MAGIC_ICON_COLOR), + imageVector = ImageVector.vectorResource(R.drawable.ic_magic_28), + contentDescription = null, + ) + } else { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_stars_20), + contentDescription = null, + ) + } SpacerW(2.dp) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsSlider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsSlider.kt index 87a5053d61..866a07785d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsSlider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/NewsSlider.kt @@ -45,7 +45,7 @@ internal fun NewsSlider(newsSliderConfig: NewsSliderConfig) { articleConfigUM = article, onArticleClick = { newsSliderConfig.callbacks.onArticleClick(article.id) }, modifier = articleModifier - .width(228.dp) + .width(280.dp) .heightIn(min = 172.dp) .fillMaxHeight(), colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), @@ -56,7 +56,7 @@ internal fun NewsSlider(newsSliderConfig: NewsSliderConfig) { item(contentType = "show_more") { ShowMoreArticlesCard( modifier = Modifier - .width(228.dp) + .width(280.dp) .heightIn(min = 172.dp) .onFirstVisible( minFractionVisible = 0.5f, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt index 56fa348c48..86b88e8137 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleCardV2.kt @@ -34,6 +34,7 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalIsInDarkTheme import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet @@ -70,12 +71,15 @@ private fun TrendingArticle( onClick = onArticleClick, ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(TangemTheme.dimens2.x4), horizontalAlignment = Alignment.Start, ) { - DayAndRatingInfo(rating = stringReference("${articleConfigUM.score}")) + DayAndRatingInfo( + rating = stringReference("${articleConfigUM.score}"), + createdAt = articleConfigUM.createdAt, + ) - SpacerH(8.dp) + SpacerH(TangemTheme.dimens2.x2) Text( text = articleConfigUM.title, @@ -88,17 +92,7 @@ private fun TrendingArticle( textAlign = TextAlign.Start, ) - SpacerH(18.dp) - - Text( - text = articleConfigUM.createdAt.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, - color = TangemTheme.colors2.text.neutral.secondary, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - SpacerH(18.dp) + SpacerH(TangemTheme.dimens2.x8) Tags(tags = articleConfigUM.tags.toImmutableList()) } @@ -111,14 +105,9 @@ internal fun ShowMoreArticlesCardV2(modifier: Modifier = Modifier, onClick: () - horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxSize() - .clip(RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(TangemTheme.dimens2.x6)) .background(color = TangemTheme.colors2.surface.level3) .clickable(onClick = onClick) - .border( - width = 1.dp, - color = TangemTheme.colors2.border.neutral.primary, - shape = RoundedCornerShape(20.dp), - ) .padding(vertical = 41.dp, horizontal = 16.dp), ) { Image( @@ -139,7 +128,7 @@ internal fun ShowMoreArticlesCardV2(modifier: Modifier = Modifier, onClick: () - Text( text = stringResourceSafe(R.string.news_stay_in_the_loop), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.secondary, ) } @@ -153,24 +142,20 @@ private fun DefaultArticle( ) { Column( modifier = modifier - .clip(RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(TangemTheme.dimens2.x6)) .background(color = TangemTheme.colors2.surface.level3) .clickable(onClick = onArticleClick) - .border( - width = 1.dp, - color = TangemTheme.colors2.border.neutral.primary, - shape = RoundedCornerShape(20.dp), - ) - .padding(16.dp), + .padding(TangemTheme.dimens2.x4), ) { Row(verticalAlignment = Alignment.CenterVertically) { RatingInfo( rating = stringReference("${articleConfigUM.score}"), isTrending = false, + createdAt = articleConfigUM.createdAt, ) } - SpacerH(8.dp) + SpacerH(TangemTheme.dimens2.x2) Text( modifier = Modifier.weight(1f), @@ -185,17 +170,7 @@ private fun DefaultArticle( overflow = TextOverflow.Ellipsis, ) - SpacerH(8.dp) - - Text( - text = articleConfigUM.createdAt.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, - color = TangemTheme.colors2.text.neutral.secondary, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - - SpacerH(8.dp) + SpacerH(TangemTheme.dimens2.x8) Tags(tags = articleConfigUM.tags.toImmutableList()) } @@ -220,7 +195,7 @@ private fun TrendingArticleBackground( Box( modifier = modifier - .clip(RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(TangemTheme.dimens2.x6)) .drawBehind { drawRect(bgColor) @@ -279,45 +254,61 @@ private fun TrendingArticleBackground( } @Composable -private fun DayAndRatingInfo(rating: TextReference, modifier: Modifier = Modifier) { +private fun DayAndRatingInfo(createdAt: TextReference, rating: TextReference, modifier: Modifier = Modifier) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { - RatingInfo(rating = rating, isTrending = true) + RatingInfo(rating = rating, isTrending = true, createdAt = createdAt) SpacerW(8.dp) Text( text = stringResourceSafe(R.string.feed_trending_now), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.primary, ) } } @Composable -private fun RatingInfo(rating: TextReference, isTrending: Boolean) { +private fun RatingInfo(rating: TextReference, isTrending: Boolean, createdAt: TextReference) { + val iconTint = if (isTrending) { + TangemTheme.colors2.fill.status.attention + } else { + TangemTheme.colors2.markers.iconGray + } + val captionColor = if (isTrending) { + TangemTheme.colors2.text.status.attention + } else { + TangemTheme.colors2.text.neutral.secondary + } + Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_wrapped_circle_star_16), - tint = if (isTrending) { - TangemTheme.colors2.fill.status.attention - } else { - TangemTheme.colors2.markers.iconGray - }, + tint = iconTint, contentDescription = null, ) - SpacerW(2.dp) + SpacerW(TangemTheme.dimens2.x1) Text( text = rating.resolveReference(), - color = if (isTrending) { - TangemTheme.colors2.text.status.attention - } else { - TangemTheme.colors2.text.neutral.secondary - }, - style = TangemTheme.typography2.captionSemibold12, + color = captionColor, + style = TangemTheme.typography2.captionMedium12, + ) + + Text( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x0_5), + text = StringsSigns.DOT, + color = captionColor, + style = TangemTheme.typography2.captionMedium12, + ) + + Text( + text = createdAt.resolveReference(), + color = captionColor, + style = TangemTheme.typography2.captionMedium12, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt index 90e3717cd0..530d0a1dc5 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/components/articles/ArticleHeader.kt @@ -3,7 +3,6 @@ package com.tangem.features.feed.ui.feed.components.articles import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -12,22 +11,16 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.components.label.Label import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelUM -import com.tangem.core.ui.ds.badge.TangemBadge -import com.tangem.core.ui.ds.badge.TangemBadgeColor -import com.tangem.core.ui.ds.badge.TangemBadgeIconPosition -import com.tangem.core.ui.ds.badge.TangemBadgeShape -import com.tangem.core.ui.ds.badge.TangemBadgeSize -import com.tangem.core.ui.ds.badge.TangemBadgeType +import com.tangem.core.ui.ds.badge.* import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.extensions.stringResourceSafe 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.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -100,7 +93,6 @@ private fun ArticleHeaderV1( } } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun ArticleHeaderV2( isTrending: Boolean, @@ -111,127 +103,121 @@ private fun ArticleHeaderV2( modifier: Modifier = Modifier, ) { Column(modifier = modifier) { - Row( - modifier = Modifier - .heightIn(min = 66.dp) - .padding(top = 16.dp), - verticalAlignment = Alignment.Bottom, - ) { - DateBlock( - modifier = Modifier.weight(1f), - createdAt = createdAt, - ) - SpacerW(30.dp) - VerticalDivider( - modifier = Modifier - .height(46.dp) - .padding(bottom = 4.dp), - color = TangemTheme.colors2.border.neutral.primary, - ) - SpacerW(30.dp) - ScoreBlock( - modifier = Modifier.weight(1f), - score = score, - isTrending = isTrending, - ) - } - - Text( - modifier = Modifier.padding(vertical = 36.dp), - text = title, - style = TangemTheme.typography2.headingBold34, - color = TangemTheme.colors2.text.neutral.primary, + ArticleHeaderV2MetaRow( + isTrending = isTrending, + score = score, + createdAt = createdAt, ) - - if (tags.isNotEmpty()) { - Spacer(modifier = Modifier.height(20.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - tags.forEach { tag -> - TangemBadge( - text = tag.text, - tangemIconUM = when (val content = tag.leadingContent) { - LabelLeadingContentUM.None -> null - is LabelLeadingContentUM.Token -> TangemIconUM.Url( - url = content.iconUrl, - fallbackRes = R.drawable.ic_alert_24, - ) - }, - shape = TangemBadgeShape.Rounded, - size = TangemBadgeSize.X9, - type = TangemBadgeType.Tinted, - color = TangemBadgeColor.Gray, - iconPosition = when (tag.leadingContent) { - LabelLeadingContentUM.None -> TangemBadgeIconPosition.None - is LabelLeadingContentUM.Token -> TangemBadgeIconPosition.Start - }, - ) - } - } - } + ArticleHeaderV2Title(title = title) + ArticleHeaderV2Tags(tags = tags) } } @Composable -private fun ScoreBlock(score: Float, isTrending: Boolean, modifier: Modifier = Modifier) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(10.dp), +private fun ArticleHeaderV2MetaRow(isTrending: Boolean, score: Float, createdAt: String) { + val starTint = if (isTrending) { + TangemTheme.colors2.fill.status.attention + } else { + TangemTheme.colors2.graphic.neutral.primary + } + val scoreColor = if (isTrending) { + TangemTheme.colors2.text.status.attention + } else { + TangemTheme.colors2.text.neutral.tertiary + } + + Row( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon( - modifier = Modifier.size(20.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_wrapped_circle_star_16), - tint = if (isTrending) { - TangemTheme.colors2.fill.status.attention - } else { - TangemTheme.colors2.graphic.neutral.primary - }, - contentDescription = null, - ) - Text( - text = score.toString(), - style = TangemTheme.typography2.bodyRegular16, - color = if (isTrending) { - TangemTheme.colors2.text.status.attention - } else { - TangemTheme.colors2.text.neutral.primary - }, - ) - } + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x5), + imageVector = ImageVector.vectorResource(R.drawable.ic_wrapped_circle_star_16), + tint = starTint, + contentDescription = null, + ) Text( - text = stringResourceSafe(R.string.news_trending_score), - style = TangemTheme.typography2.captionSemibold13, + text = score.toString(), + style = TangemTheme.typography2.bodyMedium16, + color = scoreColor, + ) + Text( + text = StringsSigns.DOT, color = TangemTheme.colors2.text.neutral.tertiary, + style = TangemTheme.typography2.bodyMedium16, ) - } -} - -@Composable -private fun DateBlock(createdAt: String, modifier: Modifier = Modifier) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { Icon( modifier = Modifier.size(20.dp), imageVector = ImageVector.vectorResource(R.drawable.ic_calendar_20), - tint = TangemTheme.colors2.fill.neutral.primary, + tint = TangemTheme.colors2.graphic.neutral.secondary, contentDescription = null, ) Text( text = createdAt, - style = TangemTheme.typography2.captionSemibold13, color = TangemTheme.colors2.text.neutral.tertiary, + style = TangemTheme.typography2.bodyMedium16, ) } } +@Composable +private fun ArticleHeaderV2Title(title: String) { + Text( + modifier = Modifier + .padding( + top = TangemTheme.dimens2.x4, + bottom = TangemTheme.dimens2.x1_5, + start = TangemTheme.dimens2.x1, + ), + text = title, + style = TangemTheme.typography2.headingSemibold28, + color = TangemTheme.colors2.text.neutral.primary, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ArticleHeaderV2Tags(tags: ImmutableList) { + if (tags.isEmpty()) return + + Spacer(modifier = Modifier.height(TangemTheme.dimens2.x6)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), + ) { + tags.forEach { tag -> + ArticleHeaderTagBadge(tag = tag) + } + } +} + +@Composable +private fun ArticleHeaderTagBadge(tag: LabelUM) { + TangemBadge( + text = tag.text, + tangemIconUM = labelLeadingIcon(tag.leadingContent), + shape = TangemBadgeShape.Rounded, + size = TangemBadgeSize.X9, + type = TangemBadgeType.Tinted, + color = TangemBadgeColor.Gray, + iconPosition = labelLeadingBadgeIconPosition(tag.leadingContent), + ) +} + +private fun labelLeadingIcon(content: LabelLeadingContentUM): TangemIconUM? = when (content) { + LabelLeadingContentUM.None -> null + is LabelLeadingContentUM.Token -> TangemIconUM.Url( + url = content.iconUrl, + fallbackRes = R.drawable.ic_alert_24, + ) +} + +private fun labelLeadingBadgeIconPosition(content: LabelLeadingContentUM): TangemBadgeIconPosition = when (content) { + LabelLeadingContentUM.None -> TangemBadgeIconPosition.None + is LabelLeadingContentUM.Token -> TangemBadgeIconPosition.Start +} + @Preview(showBackground = true, widthDp = 360) @Composable private fun ArticleHeaderPreviewV1() { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index e3a0a86a28..ed2ebcdccb 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -265,7 +265,7 @@ internal object FeedListPreviewDataProvider { } private fun createEarnListItemsUM(): ImmutableList { - return List(5) { + return List(1) { EarnListItemUM( network = stringReference("Ethereum"), symbol = stringReference("USDT"), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt index 3377a8eb08..b84d7cde77 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/MarketsTokenDetailsContent.kt @@ -125,7 +125,9 @@ private fun Content( .fillMaxWidth(), ) } - item { SpacerH16() } + item { + if (isRedesignEnabled) SpacerH(TangemTheme.dimens2.x3) else SpacerH16() + } item("intervalSelector") { IntervalSelector( trendInterval = state.selectedInterval, @@ -136,7 +138,9 @@ private fun Content( .fillMaxWidth(), ) } - item { SpacerH32() } + item { + if (isRedesignEnabled) SpacerH(TangemTheme.dimens2.x3) else SpacerH32() + } item("chart") { MarketTokenDetailsChart( modifier = Modifier.fillMaxWidth(), diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt index e6cfb24267..98b635eab6 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InformationTextBlock.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow @@ -26,8 +25,6 @@ internal fun InformationTextBlock( text: TextReference, modifier: Modifier = Modifier, onInfoClick: (() -> Unit)? = null, - textColor: Color = TangemTheme.colors2.text.neutral.tertiary, - infoIconColor: Color = TangemTheme.colors2.markers.iconGray, informationTextBlockIconPosition: InformationTextBlockIconPosition = InformationTextBlockIconPosition.START, ) { val interactionSource = remember { MutableInteractionSource() } @@ -36,7 +33,7 @@ internal fun InformationTextBlock( Icon( modifier = Modifier.size(TangemTheme.dimens2.x4), imageVector = ImageVector.vectorResource(id = R.drawable.ic_information_24), - tint = infoIconColor, + tint = TangemTheme.colors2.fill.neutral.secondary, contentDescription = null, ) } @@ -44,8 +41,8 @@ internal fun InformationTextBlock( val contentText: @Composable () -> Unit = { Text( text = text.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, - color = textColor, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt index 778af344f2..ff70bd80a1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt @@ -14,10 +14,10 @@ import com.tangem.core.ui.components.TextShimmer import com.tangem.core.ui.components.block.information.InformationBlock import com.tangem.core.ui.components.buttons.chip.Chip import com.tangem.core.ui.components.inputrow.inner.DividerContainer -import com.tangem.core.ui.ds.badge.TangemBadge -import com.tangem.core.ui.ds.badge.TangemBadgeIconPosition -import com.tangem.core.ui.ds.badge.TangemBadgeShape -import com.tangem.core.ui.ds.badge.TangemBadgeSize +import com.tangem.core.ui.ds.button.SecondaryTangemButton +import com.tangem.core.ui.ds.button.TangemButtonIconPosition +import com.tangem.core.ui.ds.button.TangemButtonShape +import com.tangem.core.ui.ds.button.TangemButtonSize import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe @@ -174,7 +174,7 @@ private fun SubBlockV2( Text( modifier = Modifier.padding(start = 10.dp, top = TangemTheme.dimens2.x4), text = title, - style = TangemTheme.typography2.bodySemibold16, + style = TangemTheme.typography2.headingSemibold20, color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -185,16 +185,16 @@ private fun SubBlockV2( verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), ) { links.fastForEach { link -> - TangemBadge( - text = stringReference(link.title), + SecondaryTangemButton( onClick = { onLinkClick(link) }, - iconPosition = TangemBadgeIconPosition.Start, + text = stringReference(link.title), + iconPosition = TangemButtonIconPosition.Start, tangemIconUM = TangemIconUM.Icon( iconRes = link.iconRes, - tintReference = { TangemTheme.colors2.markers.iconGray }, + tintReference = { TangemTheme.colors2.graphic.neutral.primary }, ), - size = TangemBadgeSize.X9, - shape = TangemBadgeShape.Rounded, + size = TangemButtonSize.X9, + shape = TangemButtonShape.Rounded, ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt index e7e041f634..a8f1472bd7 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/ListedOnBlock.kt @@ -94,18 +94,18 @@ private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) { }, title = { Row(verticalAlignment = Alignment.CenterVertically) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) { Text( text = state.title.resolveReference(), - style = TangemTheme.typography2.headingSemibold20, - color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = state.description.resolveReference(), - style = TangemTheme.typography2.captionSemibold13, - color = TangemTheme.colors2.text.neutral.secondary, + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -114,7 +114,7 @@ private fun ListedOnBlockV2(state: ListedOnUM, modifier: Modifier = Modifier) { SpacerWMax() Icon( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(TangemTheme.dimens2.x5), imageVector = ImageVector.vectorResource(R.drawable.ic_chevron_small_right_24), tint = TangemTheme.colors2.markers.iconGray, contentDescription = null, @@ -165,7 +165,7 @@ internal fun ListedOnBlockPlaceholderV2(modifier: Modifier = Modifier) { radius = TangemTheme.dimens2.x25, ) TextShimmer( - style = TangemTheme.typography2.captionSemibold13, + style = TangemTheme.typography2.captionMedium13, modifier = Modifier.width(66.dp), radius = TangemTheme.dimens2.x25, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt index 966cf8e307..c61808563d 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt @@ -42,7 +42,7 @@ import com.tangem.features.feed.ui.market.detailed.state.TrendingVolumeLiquidity internal fun MarketCapCard(item: InfoPointUMV2.MarketCap) { MetricsCard( modifier = Modifier - .heightIn(120.dp) + .heightIn(104.dp) .fillMaxWidth(), title = { MetricValueText(value = item.capitalizationValue) }, content = { @@ -66,7 +66,7 @@ internal fun TradingVolumeCard(item: InfoPointUMV2.TradingVolume) { MetricsCard( modifier = Modifier - .heightIn(120.dp) + .heightIn(104.dp) .fillMaxWidth(), title = { Row { @@ -74,7 +74,7 @@ internal fun TradingVolumeCard(item: InfoPointUMV2.TradingVolume) { Text( modifier = Modifier.padding(TangemTheme.dimens2.x1), text = stringResourceSafe(R.string.markets_token_details_trading_interval), - style = TangemTheme.typography2.captionSemibold11, + style = TangemTheme.typography2.captionMedium11, color = valueColor, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -97,24 +97,21 @@ internal fun TradingVolumeCard(item: InfoPointUMV2.TradingVolume) { SpacerH(12.dp) InformationTextBlock( text = resourceReference(R.string.markets_token_details_trading_volume), - textColor = tradingColor, - infoIconColor = tradingColor, onInfoClick = item.onInfoClick, ) } }, - cardColor = tradingColor.copy(alpha = .2f), + cardColor = TangemTheme.colors2.surface.level3, ) } @Composable internal fun MarketPositionCard(item: InfoPointUMV2.MarketPosition) { - val ratingCardColor = mapRatingToCardColor(marketRatingType = item.marketRatingType) val ratingColor = mapRatingToColor(marketRatingType = item.marketRatingType) MetricsCard( modifier = Modifier - .heightIn(120.dp) + .heightIn(104.dp) .fillMaxWidth(), title = { Row(verticalAlignment = Alignment.CenterVertically) { @@ -140,13 +137,11 @@ internal fun MarketPositionCard(item: InfoPointUMV2.MarketPosition) { SpacerH(12.dp) InformationTextBlock( text = resourceReference(R.string.markets_token_details_market_rating), - textColor = ratingColor, - infoIconColor = ratingColor, onInfoClick = item.onInfoClick, ) } }, - cardColor = ratingCardColor, + cardColor = TangemTheme.colors2.surface.level3, ) } @@ -154,7 +149,7 @@ internal fun MarketPositionCard(item: InfoPointUMV2.MarketPosition) { internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) { MetricsCard( modifier = Modifier - .heightIn(120.dp) + .heightIn(104.dp) .fillMaxWidth(), title = { if (item.fullyDilutedValuationChange24 != null) { @@ -163,7 +158,7 @@ internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) { Text( modifier = Modifier.padding(TangemTheme.dimens2.x1), text = stringResourceSafe(R.string.markets_token_details_trading_interval), - style = TangemTheme.typography2.captionSemibold11, + style = TangemTheme.typography2.captionMedium11, color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -179,7 +174,7 @@ internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) { Text( text = item.value?.resolveReference() ?: stringResourceSafe(R.string.token_market_metrics_no_data), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -200,14 +195,14 @@ internal fun FDVCard(item: InfoPointUMV2.FullyDilutedValuation) { internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) { MetricsCard( modifier = Modifier - .heightIn(min = if (item.fillValue == null) 88.dp else 114.dp) + .heightIn(min = if (item.fillValue == null) 88.dp else 106.dp) .fillMaxWidth(), title = { TangemRowContainer(contentPadding = PaddingValues(0.dp)) { Text( modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), text = stringResourceSafe(R.string.markets_token_details_circulating_supply), - style = TangemTheme.typography2.captionSemibold13, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -223,7 +218,7 @@ internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) { Text( modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), text = stringResourceSafe(R.string.markets_token_details_max_supply), - style = TangemTheme.typography2.captionSemibold13, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.tertiary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -235,7 +230,7 @@ internal fun CirculatingSupplyCard(item: InfoPointUMV2.CirculatingSupply) { .padding(top = 12.dp) .layoutId(TangemRowLayoutId.END_BOTTOM), text = item.maxValue.resolveReference(), - style = TangemTheme.typography2.headingSemibold22, + style = TangemTheme.typography2.headingSemibold20, color = TangemTheme.colors2.text.neutral.primary, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -270,7 +265,7 @@ private fun MetricValueText(value: TextReference?, modifier: Modifier = Modifier Text( modifier = modifier, text = value?.resolveReference() ?: stringResourceSafe(R.string.token_market_metrics_no_data), - style = TangemTheme.typography2.headingSemibold22, + style = TangemTheme.typography2.headingSemibold20, color = metricValueColor(hasData = value != null), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -338,7 +333,7 @@ private fun RatingChangeContent(iconRes: Int, iconTint: Color, changeValue: Stri SpacerW(2.dp) Text( text = changeValue, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = textColor, ) } @@ -366,14 +361,6 @@ private fun MarketRatingType.baseColor(): Color { @Composable private fun mapRatingToColor(marketRatingType: MarketRatingType): Color = marketRatingType.baseColor() -@Composable -private fun mapRatingToCardColor(marketRatingType: MarketRatingType): Color { - return when (marketRatingType) { - MarketRatingType.OTHER -> TangemTheme.colors2.surface.level3 - else -> marketRatingType.baseColor().copy(alpha = 0.3f) - } -} - // endregion private const val GOLD_PLACE_COLOR_NIGHT = 0xFFFBEE76 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt index dd7c78854c..ed8956d257 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/SecurityScoreBlock.kt @@ -27,7 +27,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.utils.PreviewShimmerContainer import com.tangem.features.feed.impl.R -import com.tangem.features.feed.ui.components.ContainerWithDivider +import com.tangem.features.feed.ui.components.TokenMarketInformationBlock import com.tangem.features.feed.ui.market.detailed.state.SecurityScoreUM @Composable @@ -80,45 +80,42 @@ private fun SecurityScoreBlockV1(state: SecurityScoreUM, modifier: Modifier = Mo @Composable private fun SecurityScoreBlockV2(state: SecurityScoreUM, modifier: Modifier = Modifier) { - ContainerWithDivider( + TokenMarketInformationBlock( modifier = modifier, - showDivider = true, - ) { - TangemRowContainer(modifier = Modifier.padding(top = 20.dp, bottom = 24.dp)) { - Text( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), - text = "${state.score}", - color = TangemTheme.colors2.text.neutral.primary, - style = TangemTheme.typography2.headingBold28, - ) + title = { + TangemRowContainer(contentPadding = PaddingValues()) { + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_TOP), + text = "${state.score}", + color = TangemTheme.colors2.text.neutral.primary, + style = TangemTheme.typography2.headingSemibold20, + ) - InformationTextBlock( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), - text = resourceReference(R.string.markets_token_details_security_score), - onInfoClick = state.onInfoClick, - textColor = TangemTheme.colors2.text.neutral.primary, - informationTextBlockIconPosition = InformationTextBlockIconPosition.END, - ) + InformationTextBlock( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), + text = resourceReference(R.string.markets_token_details_security_score), + onInfoClick = state.onInfoClick, + informationTextBlockIconPosition = InformationTextBlockIconPosition.START, + ) - Text( - modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), - text = state.description.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, - color = TangemTheme.colors2.text.neutral.tertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), + text = state.description.resolveReference(), + style = TangemTheme.typography2.captionMedium12, + color = TangemTheme.colors2.text.neutral.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - ScoreStarsBlock( - modifier = Modifier - .padding(bottom = 16.dp) - .layoutId(layoutId = TangemRowLayoutId.END_TOP), - score = state.score, - scoreTextStyle = TangemTheme.typography.body1, - horizontalSpacing = TangemTheme.dimens.spacing8, - ) - } - } + ScoreStarsBlock( + modifier = Modifier.layoutId(layoutId = TangemRowLayoutId.END_TOP), + score = state.score, + scoreTextStyle = TangemTheme.typography.body1, + horizontalSpacing = TangemTheme.dimens.spacing8, + ) + } + }, + ) } @Composable @@ -146,7 +143,7 @@ private fun SecurityScoreBlockPlaceholderV2(modifier: Modifier = Modifier) { .width(74.dp) .padding(top = 8.dp) .layoutId(layoutId = TangemRowLayoutId.START_BOTTOM), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, radius = TangemTheme.dimens2.x25, ) @@ -163,7 +160,7 @@ private fun SecurityScoreBlockPlaceholderV2(modifier: Modifier = Modifier) { .width(96.dp) .padding(top = 8.dp) .layoutId(layoutId = TangemRowLayoutId.END_BOTTOM), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, radius = TangemTheme.dimens2.x25, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt index b1d372de32..20520a4398 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/TokenMarketDetailsBody.kt @@ -11,7 +11,9 @@ import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.UnableToLoadData import com.tangem.core.ui.components.items.DescriptionItem import com.tangem.core.ui.components.items.DescriptionPlaceholder +import com.tangem.core.ui.extensions.conditionalCompose import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.features.feed.impl.R import com.tangem.features.feed.ui.feed.components.NewsSlider @@ -161,7 +163,18 @@ private fun LazyListScope.aboutCoinHeader() { private fun LazyListScope.description(description: MarketsTokenDetailsUM.Description) { item("description") { DescriptionItem( - modifier = Modifier.blockPaddings(), + modifier = Modifier + .conditionalCompose( + condition = LocalRedesignEnabled.current, + modifier = { + this + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x8) + }, + otherModifier = { + blockPaddings() + }, + ), description = description.shortDescription, hasFullDescription = description.fullDescription != null, onReadMoreClick = description.onReadMoreClick, @@ -249,12 +262,6 @@ internal fun LazyListScope.infoBlocksListV2(state: MarketsTokenDetailsUM.Informa ) } - if (relatedNews.articles.isNotEmpty()) { - relatedNews(relatedNews) - } else { - sectionStub(RelatedNews.SECTION_KEY) - } - if (state.securityScore != null) { item("securityScore") { SecurityScoreBlock( @@ -264,6 +271,12 @@ internal fun LazyListScope.infoBlocksListV2(state: MarketsTokenDetailsUM.Informa } } + if (relatedNews.articles.isNotEmpty()) { + relatedNewsV2(relatedNews) + } else { + sectionStub(RelatedNews.SECTION_KEY) + } + if (state.links != null) { item("links") { LinksBlock( @@ -367,11 +380,52 @@ private fun LazyListScope.relatedNews(relatedNews: RelatedNews) { } } +private fun LazyListScope.relatedNewsV2(relatedNews: RelatedNews) { + item(RelatedNews.SECTION_KEY) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = TangemTheme.dimens2.x8) + .onFirstVisible( + minFractionVisible = 0.5f, + callback = relatedNews.onFirstVisible, + ), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x3), + ) { + Text( + modifier = Modifier.padding(start = TangemTheme.dimens2.x6), + text = stringResourceSafe(R.string.news_related_news), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) + + NewsSlider( + NewsSliderConfig( + callbacks = NewsSliderCallbacks( + onOpenAllNews = {}, // not applicable here + onSliderScroll = relatedNews.onScroll, + onSliderEndReached = {}, // not applicable here + onArticleClick = relatedNews.onArticledClicked, + ), + content = relatedNews.articles, + shouldShowSeeAllNewsItem = false, + ), + ) + } + } +} + @Composable private fun Modifier.blockPaddings(): Modifier { - return this.padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - bottom = TangemTheme.dimens.spacing12, - ) + return if (LocalRedesignEnabled.current) { + this + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x2) + } else { + this.padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing12, + ) + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt index 9b7905ade3..6e71c83a26 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/NewsDetailsContent.kt @@ -22,6 +22,9 @@ import com.tangem.features.feed.ui.news.details.components.NewsDetailsPlaceholde import com.tangem.features.feed.ui.news.details.state.ArticlesStateUM import com.tangem.features.feed.ui.news.details.state.MockArticlesFactory import com.tangem.features.feed.ui.news.details.state.NewsDetailsUM +import dev.chrisbanes.haze.rememberHazeState + +private const val PAGER_BG_ALPHA = .1f @Composable internal fun NewsDetailsContent(state: NewsDetailsUM, contentPadding: PaddingValues, modifier: Modifier = Modifier) { @@ -64,6 +67,7 @@ private fun Content(contentPadding: PaddingValues, state: NewsDetailsUM, backgro initialPage = state.selectedArticleIndex, pageCount = { state.articles.size }, ) + val localHaze = rememberHazeState() if (state.articles.isNotEmpty()) { LaunchedEffect(pagerState) { @@ -98,6 +102,7 @@ private fun Content(contentPadding: PaddingValues, state: NewsDetailsUM, backgro onLikeClick = { state.onLikeClick(article.id) }, relatedTokensUM = state.relatedTokensUM, contentPadding = contentPadding, + hazeState = localHaze, ) } if (state.articles.size > 1) { @@ -108,8 +113,9 @@ private fun Content(contentPadding: PaddingValues, state: NewsDetailsUM, backgro .align(Alignment.BottomCenter) .windowInsetsPadding(WindowInsets.navigationBars), colors = TangemPagerIndicatorColors.copy( - overlay = TangemTheme.colors2.tabs.backgroundSecondary.copy(alpha = .1f), + overlay = TangemTheme.colors2.tabs.backgroundSecondary.copy(PAGER_BG_ALPHA), ), + hazeState = localHaze, ) } else { PagerIndicator( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/ArticleDetail.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/ArticleDetail.kt index 4b9ac978b3..d7fdd670cf 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/ArticleDetail.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/ArticleDetail.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -19,7 +18,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.BottomFade -import com.tangem.core.ui.components.BottomFadeWithBlur import com.tangem.core.ui.components.SecondaryButtonIconStart import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.buttons.common.TangemButtonSize @@ -38,10 +36,11 @@ import com.tangem.features.feed.ui.feed.components.articles.ArticleHeader import com.tangem.features.feed.ui.news.details.state.ArticleUM import com.tangem.features.feed.ui.news.details.state.RelatedArticleUM import com.tangem.features.feed.ui.news.details.state.RelatedTokensUM -import dev.chrisbanes.haze.rememberHazeState +import dev.chrisbanes.haze.HazeState @Composable internal fun ArticleDetail( + hazeState: HazeState, contentPadding: PaddingValues, article: ArticleUM, onLikeClick: () -> Unit, @@ -55,6 +54,7 @@ internal fun ArticleDetail( onLikeClick = onLikeClick, relatedTokensUM = relatedTokensUM, modifier = modifier, + hazeState = hazeState, ) } else { ArticleDetailV1( @@ -195,7 +195,8 @@ private fun ArticleDetailV1( @Suppress("LongMethod") @Composable -internal fun ArticleDetailV2( +private fun ArticleDetailV2( + hazeState: HazeState, contentPadding: PaddingValues, article: ArticleUM, onLikeClick: () -> Unit, @@ -206,16 +207,16 @@ internal fun ArticleDetailV2( val density = LocalDensity.current val background = LocalMainBottomSheetColor.current.value val pagerHeight = 32.dp - val bottomPadding = pagerHeight + 56.dp + with(density) { + val bottomPadding = pagerHeight + TangemTheme.dimens2.x4 + with(density) { WindowInsets.navigationBars.getBottom(this).div(this.density) }.dp - CompositionLocalProvider(LocalHazeState provides rememberHazeState()) { + CompositionLocalProvider(LocalHazeState provides hazeState) { Box(modifier = modifier) { LazyColumn( modifier = Modifier .fillMaxSize() - .hazeSourceTangem(zIndex = -1f) + .hazeSourceTangem(hazeState) .background(background), contentPadding = PaddingValues(bottom = bottomPadding, top = contentPadding.calculateTopPadding()), ) { @@ -227,39 +228,27 @@ internal fun ArticleDetailV2( tags = article.tags, isTrending = article.isTrending, modifier = Modifier - .padding(top = 16.dp) - .padding(horizontal = 16.dp), + .padding(top = TangemTheme.dimens2.x1_5, bottom = TangemTheme.dimens2.x6) + .padding(horizontal = TangemTheme.dimens2.x4), ) if (article.shortContent.isNotEmpty()) { QuickRecap( content = article.shortContent, - modifier = Modifier - .padding(top = 32.dp) - .padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), ) } Text( text = article.content, - style = TangemTheme.typography2.bodyRegular16, + style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors2.text.neutral.primary, modifier = Modifier - .padding(top = 12.dp) - .padding(horizontal = 16.dp), + .padding(vertical = TangemTheme.dimens2.x5, horizontal = TangemTheme.dimens2.x6), ) - SpacerH(24.dp) - - HorizontalDivider( - modifier = Modifier.padding(horizontal = 24.dp), - color = TangemTheme.colors2.border.neutral.primary, - ) - - SpacerH(20.dp) - SecondaryTangemButton( - modifier = Modifier.padding(horizontal = 24.dp), + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x6), text = resourceReference(R.string.news_like), size = com.tangem.core.ui.ds.button.TangemButtonSize.X9, tangemIconUM = if (article.isLiked) { @@ -286,31 +275,27 @@ internal fun ArticleDetailV2( is RelatedTokensUM.Content -> relatedTokensUM.onTokenClick else -> null }, - modifier = Modifier.padding(horizontal = 16.dp), + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), ) if (article.relatedArticles.isNotEmpty()) { - SpacerH(24.dp) - Row( - modifier = Modifier.padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResourceSafe(R.string.news_sources), - style = TangemTheme.typography2.headingSemibold20, - color = TangemTheme.colors2.text.neutral.primary, - ) - } + SpacerH(TangemTheme.dimens2.x4) + Text( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x6), + text = stringResourceSafe(R.string.news_sources), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) } } if (article.relatedArticles.isNotEmpty()) { item("relatedArticles") { LazyRow( - modifier = Modifier.padding(vertical = 12.dp), + modifier = Modifier.padding(vertical = TangemTheme.dimens2.x3), state = rememberLazyListState(), - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = TangemTheme.dimens2.x4), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x3), ) { items( items = article.relatedArticles, @@ -326,12 +311,12 @@ internal fun ArticleDetailV2( } } - BottomFadeWithBlur( + BottomFade( modifier = Modifier .align(Alignment.BottomCenter) - .height(80.dp) .fillMaxWidth(), backgroundColor = background, + height = TangemTheme.dimens2.x15, ) } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt index dc9107030f..e102f1591a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/NewsDetailsPlaceholder.kt @@ -3,7 +3,6 @@ package com.tangem.features.feed.ui.news.details.components import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -111,38 +110,12 @@ private fun NewsDetailsPlaceholderV2(contentPadding: PaddingValues, background: .padding(16.dp), ) { SpacerH(contentPadding.calculateTopPadding()) - Row( - modifier = Modifier.height(50.dp), - horizontalArrangement = Arrangement.spacedBy(30.dp), - ) { - Column { - RectangleShimmer( - modifier = Modifier.size(width = 50.dp, height = 20.dp), - radius = TangemTheme.dimens2.x25, - ) - SpacerH(10.dp) - RectangleShimmer( - modifier = Modifier.size(width = 90.dp, height = 18.dp), - radius = TangemTheme.dimens2.x25, - ) - } + RectangleShimmer( + modifier = Modifier.size(width = 90.dp, height = 20.dp), + radius = TangemTheme.dimens2.x25, + ) - VerticalDivider(color = TangemTheme.colors2.border.neutral.primary) - - Column { - RectangleShimmer( - modifier = Modifier.size(width = 50.dp, height = 20.dp), - radius = TangemTheme.dimens2.x25, - ) - SpacerH(10.dp) - RectangleShimmer( - modifier = Modifier.size(width = 90.dp, height = 18.dp), - radius = TangemTheme.dimens2.x25, - ) - } - } - - SpacerH(36.dp) + SpacerH(16.dp) RectangleShimmer( modifier = Modifier @@ -161,7 +134,7 @@ private fun NewsDetailsPlaceholderV2(contentPadding: PaddingValues, background: radius = TangemTheme.dimens2.x25, ) - SpacerH(36.dp) + SpacerH(30.dp) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { RectangleShimmer( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/QuickRecap.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/QuickRecap.kt index 6cc0ec1434..5730440a49 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/QuickRecap.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/QuickRecap.kt @@ -82,7 +82,7 @@ private fun QuickRecapV2(content: String, modifier: Modifier = Modifier) { contentDescription = null, ) - SpacerW(2.dp) + SpacerW(TangemTheme.dimens2.x1) Text( text = buildAnnotatedString { @@ -97,26 +97,26 @@ private fun QuickRecapV2(content: String, modifier: Modifier = Modifier) { append(stringResourceSafe(R.string.news_quick_recap)) } }, - style = TangemTheme.typography2.bodyRegular14, + style = TangemTheme.typography2.subheadlineMedium14, overflow = TextOverflow.Ellipsis, maxLines = 1, ) } - SpacerH(10.dp) + SpacerH(TangemTheme.dimens2.x2_5) Box { VerticalDivider( modifier = Modifier .fillMaxHeight() - .padding(start = 10.dp), - thickness = 2.dp, + .padding(start = TangemTheme.dimens2.x2_5), + thickness = TangemTheme.dimens2.x0_5, color = Color(QUICK_RECAP_DIVIDER_COLOR), ) Text( - modifier = Modifier.padding(start = 20.dp), + modifier = Modifier.padding(start = TangemTheme.dimens2.x5), text = content, - style = TangemTheme.typography2.bodyRegular16, + style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors2.text.neutral.primary, ) } @@ -138,7 +138,7 @@ private fun QuickRecapPreview() { } private const val QUICK_RECAP_DIVIDER_COLOR = 0xFFA99FFF -private const val LINEAR_GRADIENT_FIRST_PART = 0xFFA3A0FF -private const val LINEAR_GRADIENT_SECOND_PART = 0xFFF79DFF +private const val LINEAR_GRADIENT_FIRST_PART = 0xFF7B78FF +private const val LINEAR_GRADIENT_SECOND_PART = 0xFFC56BCD private const val GRADIENT_START = 0f private const val GRADIENT_END = 0.5f \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedNewsItem.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedNewsItem.kt index a3810b1bda..f99d0eff17 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedNewsItem.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedNewsItem.kt @@ -1,7 +1,6 @@ package com.tangem.features.feed.ui.news.details.components import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -113,42 +112,37 @@ private fun RelatedNewsItemV1(relatedArticle: RelatedArticleUM, modifier: Modifi private fun RelatedNewsItemV2(relatedArticle: RelatedArticleUM, modifier: Modifier = Modifier) { Column( modifier = modifier - .sizeIn(maxWidth = 228.dp, minHeight = 160.dp) + .sizeIn(maxWidth = 280.dp, minHeight = 164.dp) .background( color = TangemTheme.colors2.surface.level3, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), + shape = RoundedCornerShape(TangemTheme.dimens2.x6), ) .clickable(onClick = relatedArticle.onClick) - .border( - width = 1.dp, - color = TangemTheme.colors2.border.neutral.primary, - shape = RoundedCornerShape(TangemTheme.dimens2.x5), - ) - .padding(16.dp), + .padding(TangemTheme.dimens2.x4), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x4)) { Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { Icon( painter = painterResource(id = R.drawable.ic_explore_16), contentDescription = null, tint = TangemTheme.colors2.markers.iconGray, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(TangemTheme.dimens2.x4), ) - SpacerW(2.dp) + SpacerW(TangemTheme.dimens2.x0_5) Text( text = relatedArticle.media.name, - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, maxLines = 1, overflow = TextOverflow.Ellipsis, color = TangemTheme.colors2.text.neutral.secondary, ) } if (relatedArticle.title.isNotEmpty()) { - SpacerH(8.dp) + SpacerH(TangemTheme.dimens2.x2) Text( text = relatedArticle.title, - style = TangemTheme.typography2.bodyRegular16, + style = TangemTheme.typography2.bodyMedium16, color = TangemTheme.colors2.text.neutral.primary, maxLines = 3, overflow = TextOverflow.Ellipsis, @@ -181,7 +175,7 @@ private fun RelatedNewsItemV2(relatedArticle: RelatedArticleUM, modifier: Modifi SpacerHMax() Text( text = relatedArticle.publishedAt.resolveReference(), - style = TangemTheme.typography2.captionSemibold12, + style = TangemTheme.typography2.captionMedium12, color = TangemTheme.colors2.text.neutral.secondary, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt index 7f37764ee5..8013858af1 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/news/details/components/RelatedTokensBlock.kt @@ -1,8 +1,11 @@ package com.tangem.features.feed.ui.news.details.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -28,7 +31,27 @@ internal fun RelatedTokensBlock( onItemClick: ((MarketsListItemUM) -> Unit)?, modifier: Modifier = Modifier, ) { - val isRedesignEnabled = LocalRedesignEnabled.current + if (LocalRedesignEnabled.current) { + RelatedTokensBlockV2( + relatedTokensUM = relatedTokensUM, + onItemClick = onItemClick, + modifier = modifier, + ) + } else { + RelatedTokensBlockV1( + relatedTokensUM = relatedTokensUM, + onItemClick = onItemClick, + modifier = modifier, + ) + } +} + +@Composable +internal fun RelatedTokensBlockV1( + relatedTokensUM: RelatedTokensUM, + onItemClick: ((MarketsListItemUM) -> Unit)?, + modifier: Modifier = Modifier, +) { val isVisible = remember(relatedTokensUM) { when (relatedTokensUM) { is RelatedTokensUM.Content -> relatedTokensUM.items.isNotEmpty() @@ -41,30 +64,15 @@ internal fun RelatedTokensBlock( Column(modifier = modifier) { SpacerH(40.dp) - if (isRedesignEnabled) { - Text( - modifier = Modifier.padding(horizontal = 8.dp), - text = stringResourceSafe(R.string.news_related_tokens), - style = TangemTheme.typography2.headingSemibold20, - color = TangemTheme.colors2.text.neutral.primary, - ) - } else { - Text( - text = stringResourceSafe(R.string.news_related_tokens), - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - ) - } + Text( + text = stringResourceSafe(R.string.news_related_tokens), + style = TangemTheme.typography.h3, + color = TangemTheme.colors.text.primary1, + ) SpacerH(12.dp) BlockCard( - colors = TangemBlockCardColors.copy( - containerColor = if (isRedesignEnabled) { - TangemTheme.colors2.surface.level3 - } else { - TangemTheme.colors.background.action - }, - ), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors.background.action), ) { Column(modifier = Modifier.fillMaxWidth()) { when (relatedTokensUM) { @@ -86,4 +94,72 @@ internal fun RelatedTokensBlock( } } } +} + +@Composable +internal fun RelatedTokensBlockV2( + relatedTokensUM: RelatedTokensUM, + onItemClick: ((MarketsListItemUM) -> Unit)?, + modifier: Modifier = Modifier, +) { + val isVisible = remember(relatedTokensUM) { + when (relatedTokensUM) { + is RelatedTokensUM.Content -> relatedTokensUM.items.isNotEmpty() + RelatedTokensUM.Loading -> true + RelatedTokensUM.LoadingError -> false + } + } + + if (!isVisible) return + + Column(modifier = modifier) { + SpacerH(TangemTheme.dimens2.x6) + Text( + modifier = Modifier + .padding(horizontal = TangemTheme.dimens2.x2) + .padding(top = TangemTheme.dimens2.x4, bottom = TangemTheme.dimens2.x2), + text = stringResourceSafe(R.string.news_related_tokens), + style = TangemTheme.typography2.headingSemibold20, + color = TangemTheme.colors2.text.neutral.primary, + ) + SpacerH(TangemTheme.dimens2.x3) + + Column(modifier = Modifier.fillMaxWidth()) { + when (relatedTokensUM) { + is RelatedTokensUM.Content -> { + relatedTokensUM.items.fastForEach { marketsListItemUM -> + WithDecorated { + MarketsListItem( + model = marketsListItemUM, + onClick = { onItemClick?.invoke(marketsListItemUM) }, + ) + } + SpacerH(TangemTheme.dimens2.x2) + } + } + RelatedTokensUM.Loading -> { + repeat(RELATED_TOKEN_MAX_COUNT) { + WithDecorated { + MarketsListItemPlaceholder() + } + SpacerH(TangemTheme.dimens2.x2) + } + } + RelatedTokensUM.LoadingError -> Unit + } + } + } +} + +@Composable +private fun WithDecorated(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = TangemTheme.colors2.surface.level3, + shape = RoundedCornerShape(TangemTheme.dimens2.x5), + ), + content = content, + ) } \ No newline at end of file From a99a1b370a5568fdc538661e324734895677b86b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 12:50:14 +0500 Subject: [PATCH 156/206] Updated on 2026-08-14 --- .../SwapTransactionErrorStateConverter.kt | 5 +- .../tangem/feature/swap/model/SwapModel.kt | 35 +- .../swap/model/SwapNotificationsFactory.kt | 60 +- .../feature/swap/models/SwapStateHolder.kt | 1 + .../swap/models/states/SwapNotificationUM.kt | 12 +- .../feature/swap/ui/AutosizeTextField.kt | 9 + .../tangem/feature/swap/ui/StateBuilder.kt | 93 +- .../tangem/feature/swap/ui/TransactionCard.kt | 26 +- .../ui/preview/SwapTransactionCardPreview.kt | 5 +- .../tangem/feature/swap/utils/SwapUtils.kt | 46 +- .../DefaultInitialCurrenciesResolverTest.kt | 397 ++++++++ .../swap/StateBuilderInitialStateTest.kt | 592 ++++++++++++ .../feature/swap/StateBuilderPairsTest.kt | 408 +++++++++ .../feature/swap/StateBuilderQuotesTest.kt | 844 ++++++++++++++++++ .../feature/swap/StateBuilderSwapDataTest.kt | 603 +++++++++++++ 15 files changed, 3076 insertions(+), 60 deletions(-) create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapTransactionErrorStateConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapTransactionErrorStateConverter.kt index 653bb2b5e3..e3eb7465a2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapTransactionErrorStateConverter.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/SwapTransactionErrorStateConverter.kt @@ -4,6 +4,7 @@ import com.tangem.common.ui.alerts.TransactionErrorDialogFactory import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.feature.swap.domain.models.ui.SwapTransactionState +import com.tangem.feature.swap.model.toExpressError import com.tangem.feature.swap.models.SwapAlertUM import com.tangem.feature.swap.utils.getExpressErrorMessage import com.tangem.utils.converter.Converter @@ -17,14 +18,14 @@ internal class SwapTransactionErrorStateConverter( return when (value) { is SwapTransactionState.Error.TransactionError -> { when (val error = value.error) { - is SendTransactionError.UserCancelledError -> return null + is SendTransactionError.UserCancelledError -> null null -> SwapAlertUM.genericError(onDismiss) else -> transactionErrorDialogFactory.create(error, onDismiss, onSupportClick) } } is SwapTransactionState.Error.ExpressError -> { SwapAlertUM.expressErrorAlert( - message = getExpressErrorMessage(value.error), + message = getExpressErrorMessage(value.error.toExpressError()), onConfirmClick = { onSupportClick(value.error.code.toString()) }, ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index a1ea402b4b..7778741e7a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -222,6 +222,7 @@ internal class SwapModel @Inject constructor( private val fromTokenBalanceJobHolder = JobHolder() private val toTokenBalanceJobHolder = JobHolder() + private val swapPairsJobHolder = JobHolder() private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null @@ -527,6 +528,11 @@ internal class SwapModel @Inject constructor( private fun initSwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { modelScope.launch { + uiState = stateBuilder.createInitialLoadingState( + uiStateHolder = uiState, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) swapInteractor.getPair( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, @@ -537,9 +543,11 @@ internal class SwapModel @Inject constructor( }, ).fold( ifLeft = { error -> - handleSwapNotSupported( + uiState = stateBuilder.createInitialErrorState( + uiStateHolder = uiState, fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, + expressError = error, + onRetry = { retrySwapPairs(fromSwapCurrencyStatus, toSwapCurrencyStatus) }, ) TangemLogger.e("Error getting swap pair", error) }, @@ -555,6 +563,22 @@ internal class SwapModel @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, ) } else { + uiState = stateBuilder.updateCurrenciesState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, + ) + }, + ), + ), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + shouldResetAmount = false, + ) dataState = dataState.copy( pairs = pairs, selectedPairProviders = providerList, @@ -569,7 +593,12 @@ internal class SwapModel @Inject constructor( } }, ) - } + }.saveIn(swapPairsJobHolder) + } + + private fun retrySwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { + if (swapPairsJobHolder.isActive) return + initSwapPairs(fromSwapCurrencyStatus, toSwapCurrencyStatus) } @Suppress("UnusedPrivateMember") diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 5406397bbd..2bff6bb705 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -10,6 +10,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.parseBigDecimal +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork @@ -49,9 +50,15 @@ internal class SwapNotificationsFactory( ) } - fun getNotAvailableStateNotifications(fromCurrencyName: String): ImmutableList { + fun getErrorStateNotification( + expressError: ExpressError, + onRetryClick: () -> Unit, + ): ImmutableList { return persistentListOf( - SwapNotificationUM.Warning.NoAvailableTokensToSwap(fromCurrencyName), + SwapNotificationUM.Warning.ExpressErrorWarning( + expressError = expressError, + onConfirmClick = onRetryClick, + ), ) } @@ -363,14 +370,55 @@ internal class SwapNotificationsFactory( crypto(fromToken.symbol, fromToken.decimals) }, ) - else -> SwapNotificationUM.Warning.ExpressError( - expressDataError, - onConfirmClick = onRetryClick, - ) + else -> { + val expressError = expressDataError.toExpressError() + SwapNotificationUM.Warning.ExpressErrorWarning( + expressError = expressError, + onConfirmClick = onRetryClick, + ) + } } } private fun needShowNetworkFeeCoverageWarningShow(quoteModel: SwapState.QuotesLoadedState): Boolean { return quoteModel.currencyCheck?.existentialDeposit == null } +} + +@Deprecated("Remove with ExpressDataError") +@Suppress("CyclomaticComplexMethod") +internal fun ExpressDataError.toExpressError(): ExpressError = when (this) { + is ExpressDataError.BadRequest -> ExpressError.BadRequest(code) + is ExpressDataError.SwapsAreUnavailableNowError -> ExpressError.Forbidden(code) + is ExpressDataError.ExchangeProviderNotFoundError -> ExpressError.ProviderNotFoundError(code) + is ExpressDataError.ExchangeProviderNotActiveError -> ExpressError.ProviderNotActiveError(code) + is ExpressDataError.ExchangeProviderNotAvailableError -> ExpressError.ProviderNotAvailableError(code) + is ExpressDataError.ExchangeProviderProviderInternalError -> ExpressError.ProviderInternalError(code) + is ExpressDataError.ExchangeNotPossibleError -> ExpressError.ExchangeNotPossibleError(code) + is ExpressDataError.ExchangeNotEnoughBalanceError -> ExpressError.NotEnoughBalanceError(code) + is ExpressDataError.ExchangeInvalidAddressError -> ExpressError.InvalidAddressError(code) + is ExpressDataError.ExchangeTooSmallAmountError -> ExpressError.AmountError.TooSmallError(code, amount.value) + is ExpressDataError.ExchangeTooBigAmountError -> ExpressError.AmountError.TooBigError(code, amount.value) + is ExpressDataError.ExchangeNotEnoughAllowanceError -> ExpressError.AmountError.NotEnoughAllowanceError( + code = code, + amount = currentAllowance, + ) + is ExpressDataError.ExchangeInvalidFromDecimalsError -> ExpressError.InvalidFromDecimalsError( + code = code, + receivedFromDecimals = receivedFromDecimals, + expressFromDecimals = expressFromDecimals, + ) + is ExpressDataError.ProviderDifferentAmountError -> ExpressError.ProviderDifferentAmountError( + code = code, + fromAmount = fromAmount, + fromProviderAmount = fromProviderAmount, + decimals = decimals, + ) + is ExpressDataError.InvalidSignatureError -> ExpressError.InvalidSignatureError(code) + is ExpressDataError.InvalidRequestIdError -> ExpressError.InvalidRequestIdError(code) + is ExpressDataError.InvalidPayoutAddressError -> ExpressError.InvalidPayoutAddressError(code) + is ExpressDataError.UnknownErrorWithCode -> ExpressError.InternalError(code) + ExpressDataError.UnknownError -> ExpressError.UnknownError + ExpressDataError.TooLargeSolanaTransactionError -> ExpressError.TooLargeSolanaTransactionError() + ExpressDataError.DexActiveSupplyError -> ExpressError.DexActiveSupplyError() } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index f45012a0b1..95f8c7c400 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -86,6 +86,7 @@ sealed interface TransactionCardType { val onFocusChanged: ((Boolean) -> Unit), override val inputError: InputError, override val accountTitleUM: AccountTitleUM, + val isEnabled: Boolean, ) : TransactionCardType data class ReadOnly( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index ff6f8b47ce..1cb665edc9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -1,14 +1,14 @@ package com.tangem.feature.swap.models.states import com.tangem.common.ui.R +import com.tangem.common.ui.extensions.networkIconResId import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.TextReference -import com.tangem.common.ui.extensions.networkIconResId import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.utils.getExpressErrorMessage import com.tangem.feature.swap.utils.getExpressErrorTitle @@ -171,12 +171,12 @@ internal object SwapNotificationUM { ), ) - data class ExpressError( - val expressDataError: ExpressDataError, + data class ExpressErrorWarning( + val expressError: ExpressError, val onConfirmClick: () -> Unit, ) : Warning( - title = getExpressErrorTitle(expressDataError), - subtitle = getExpressErrorMessage(expressDataError), + title = getExpressErrorTitle(expressError), + subtitle = getExpressErrorMessage(expressError), buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference(R.string.warning_button_refresh), onClick = onConfirmClick, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt index 2a6b4ed8ea..8e2e551bd2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/AutosizeTextField.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.text.selection.TextSelectionColors import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -31,12 +32,19 @@ import com.tangem.core.ui.res.TangemTheme internal fun AutoSizeTextField( textFieldValue: TextFieldValue, focusRequester: FocusRequester, + isEnabled: Boolean, onAmountChange: (String) -> Unit, onFocusChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current + LaunchedEffect(isEnabled) { + if (!isEnabled) { + focusManager.clearFocus() + } + } + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { var shrunkFontSize = TangemTheme.typography.h2.fontSize val calculateIntrinsics = @Composable { @@ -77,6 +85,7 @@ internal fun AutoSizeTextField( imeAction = ImeAction.Done, keyboardType = KeyboardType.Decimal, ), + enabled = isEnabled, keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), decorationBox = { innerTextField -> if (textFieldValue.text.isBlank()) { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index db56f3b0a1..deb2acdde2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -14,7 +14,9 @@ import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig import com.tangem.core.ui.extensions.* import com.tangem.core.ui.format.bigdecimal.* import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -38,6 +40,7 @@ import com.tangem.utils.Provider import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.TILDE_SIGN +import com.tangem.utils.extensions.orZero import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -106,11 +109,13 @@ internal class StateBuilder( swapCurrencyStatus = fromSwapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = true, + isEnabled = toSwapCurrencyStatus != null, ), receiveCardData = createCardState( swapCurrencyStatus = toSwapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = false, + isEnabled = true, ), notifications = persistentListOf(), isInsufficientFunds = false, @@ -128,6 +133,78 @@ internal class StateBuilder( ) } + fun createInitialErrorState( + fromSwapCurrencyStatus: SwapCurrencyStatus?, + uiStateHolder: SwapStateHolder, + expressError: ExpressError, + onRetry: () -> Unit, + ): SwapStateHolder { + return uiStateHolder.copy( + sendCardData = (uiStateHolder.sendCardData as? SwapCardState.SwapCardData)?.copy( + type = (uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable)?.copy( + isEnabled = false, + ) ?: uiStateHolder.sendCardData.type, + ) ?: uiStateHolder.sendCardData, + notifications = notificationsFactory.getErrorStateNotification( + expressError = expressError, + onRetryClick = onRetry, + ), + permissionUM = SwapPermissionUM.Empty, + fee = FeeItemState.Empty, + swapButton = fromSwapCurrencyStatus?.let { + SwapButton( + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), + isEnabled = false, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, + onClick = actions.onSwapClick, + ) + } ?: uiStateHolder.swapButton, + changeCardsButtonState = ChangeCardsButtonState.ENABLED, + providerState = ProviderState.Empty(), + priceImpact = PriceImpact.Empty, + tosState = null, + ) + } + + fun createInitialLoadingState( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + uiStateHolder: SwapStateHolder, + ): SwapStateHolder { + val fromCurrency = fromSwapCurrencyStatus.currency + val toCurrency = toSwapCurrencyStatus.currency + if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder + if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder + return uiStateHolder.copy( + sendCardData = uiStateHolder.sendCardData.copy( + type = TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + isEnabled = true, + ), + ), + receiveCardData = uiStateHolder.receiveCardData.copy( + type = TransactionCardType.ReadOnly( + accountTitleUM = getCardAccountTitle(toSwapCurrencyStatus.account, isFromCard = false), + ), + ), + notifications = persistentListOf(), + fee = FeeItemState.Empty, + swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(fromSwapCurrencyStatus.userWallet), + isEnabled = false, + isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet, + onClick = {}, + ), + providerState = ProviderState.Empty(), + changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS, + priceImpact = PriceImpact.Empty, + shouldShowMaxAmount = shouldShowMaxAmount(fromCurrency, toCurrency), + ) + } + fun updateCurrenciesState( uiStateHolder: SwapStateHolder, emptyAmountState: SwapState.EmptyAmountState, @@ -141,12 +218,14 @@ internal class StateBuilder( emptyAmountState = emptyAmountState, isFromCard = true, shouldResetAmount = shouldResetAmount, + isEnabled = toSwapCurrencyStatus != null, ), receiveCardData = uiStateHolder.receiveCardData.updateCurrencyStatus( swapCurrencyStatus = toSwapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = false, shouldResetAmount = shouldResetAmount, + isEnabled = true, ), notifications = persistentListOf(), isInsufficientFunds = false, @@ -169,6 +248,7 @@ internal class StateBuilder( emptyAmountState: SwapState.EmptyAmountState, shouldResetAmount: Boolean, isFromCard: Boolean, + isEnabled: Boolean, ): SwapCardState { val cardType = if (isFromCard) { TransactionCardType.Inputtable( @@ -176,6 +256,7 @@ internal class StateBuilder( onFocusChanged = actions.onAmountSelected, inputError = TransactionCardType.InputError.Empty, accountTitleUM = getCardAccountTitle(swapCurrencyStatus?.account, true), + isEnabled = isEnabled, ) } else { TransactionCardType.ReadOnly( @@ -188,6 +269,7 @@ internal class StateBuilder( swapCurrencyStatus = swapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = isFromCard, + isEnabled = isEnabled, ) } else if (shouldResetAmount) { copy( @@ -218,6 +300,7 @@ internal class StateBuilder( swapCurrencyStatus: SwapCurrencyStatus?, emptyAmountState: SwapState.EmptyAmountState, isFromCard: Boolean, + isEnabled: Boolean, ): SwapCardState { return if (swapCurrencyStatus == null) { getEmptyCardState(isFromCard = isFromCard, emptyAmountState = emptyAmountState) @@ -229,6 +312,7 @@ internal class StateBuilder( onFocusChanged = actions.onAmountSelected, inputError = TransactionCardType.InputError.Empty, accountTitleUM = getCardAccountTitle(swapCurrencyStatus.account, true), + isEnabled = isEnabled, ) } else { TransactionCardType.ReadOnly( @@ -328,6 +412,7 @@ internal class StateBuilder( onFocusChanged = actions.onAmountSelected, inputError = TransactionCardType.InputError.Empty, accountTitleUM = getCardAccountTitle(fromSwapCurrencyStatus.account, isFromCard = true), + isEnabled = true, ), ), receiveCardData = uiStateHolder.receiveCardData.copy( @@ -509,7 +594,7 @@ internal class StateBuilder( private fun getSwapButtonEnabled(notifications: ImmutableList, priceImpact: PriceImpact): Boolean { return notifications.none { notification -> notification is SwapNotificationUM.Error || notification is NotificationUM.Error || - notification is SwapNotificationUM.Warning.ExpressError || + notification is SwapNotificationUM.Warning.ExpressErrorWarning || notification is SwapNotificationUM.Warning.ExpressGeneralError || notification is SwapNotificationUM.Warning.NoAvailableTokensToSwap || notification is SwapNotificationUM.Warning.SwapNotSupported || @@ -686,7 +771,7 @@ internal class StateBuilder( minTxAmount: BigDecimal?, ): SwapStateHolder { if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState - val amountToSend = amountRaw.toBigDecimalOrNull() + val amountToSend = amountRaw.parseBigDecimalOrNull() val sendInput = if (minTxAmount != null && amountToSend != null && amountToSend < minTxAmount) { val minAmountFormatted = minTxAmount.format { crypto(cryptoCurrency = fromSwapCurrencyStatus.currency, ignoreSymbolPosition = true) @@ -711,7 +796,7 @@ internal class StateBuilder( ), amountEquivalent = getFormattedFiatAmount( fromSwapCurrencyStatus.status.value.fiatRate?.let { fiatRate -> - amountToSend?.multiply(fiatRate) + amountToSend?.multiply(fiatRate).orZero() }, ), type = sendInput, @@ -731,12 +816,14 @@ internal class StateBuilder( emptyAmountState = emptyAmountState, isFromCard = true, shouldResetAmount = false, + isEnabled = toSwapCurrencyStatus != null, ), receiveCardData = uiState.receiveCardData.updateCurrencyStatus( swapCurrencyStatus = toSwapCurrencyStatus, emptyAmountState = emptyAmountState, isFromCard = false, shouldResetAmount = false, + isEnabled = true, ), ) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 0b5497b495..d511cdb1ce 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -296,25 +296,16 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie } else { TangemTheme.colors.text.warning } - AnimatedContent( - targetState = type.accountTitleUM, - label = "", - ) { currentAccountTitle -> - if (currentAccountTitle != null) { - AccountTitle( - accountTitleUM = currentAccountTitle, - textColor = titleColor, - ) - } else { - TextShimmer( - text = stringResourceSafe(R.string.swapping_to_title), - style = TangemTheme.typography.subtitle2, - ) - } - } + AccountTitle( + accountTitleUM = type.accountTitleUM, + textColor = titleColor, + ) SpacerW16() if (balance.isNotBlank()) { - AnimatedContent(targetState = balance, label = "") { balanceText -> + AnimatedContent( + targetState = balance, + label = "", + ) { balanceText -> Text( text = balanceText, color = TangemTheme.colors.text.tertiary, @@ -391,6 +382,7 @@ private fun Content( modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD), focusRequester = focusRequester, textFieldValue = textFieldValue ?: TextFieldValue(), + isEnabled = type.isEnabled, onAmountChange = { type.onAmountChanged(it) }, onFocusChange = type.onFocusChanged, ) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt index 04fc89a1a1..c64731f361 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/preview/SwapTransactionCardPreview.kt @@ -24,12 +24,13 @@ internal object SwapTransactionCardPreview { name = AccountNameUM.DefaultMain.value, icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()), ), + isEnabled = true, ), amountTextFieldValue = TextFieldValue(), amountEquivalent = stringReference("1 000 000"), currencyIconState = CurrencyIconState.Loading, tokenSymbol = stringReference("DAI"), - balance = "123", + balance = "123123123.123123", isBalanceHidden = false, ) @@ -63,6 +64,7 @@ internal object SwapTransactionCardPreview { onFocusChanged = {}, inputError = TransactionCardType.InputError.Empty, accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_from_title)), + isEnabled = false, ), amountEquivalent = stringReference("$0.00"), amountTextFieldValue = null, @@ -74,6 +76,7 @@ internal object SwapTransactionCardPreview { onFocusChanged = {}, inputError = TransactionCardType.InputError.Empty, accountTitleUM = AccountTitleUM.Text(title = resourceReference(R.string.swapping_to_title)), + isEnabled = false, ), ) } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/utils/SwapUtils.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/utils/SwapUtils.kt index 3093dcb07b..93787c70f4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/utils/SwapUtils.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/utils/SwapUtils.kt @@ -5,48 +5,50 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.simple +import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.presentation.R -internal fun getExpressErrorMessage(expressDataError: ExpressDataError): TextReference { - return when (expressDataError) { - is ExpressDataError.SwapsAreUnavailableNowError -> resourceReference( +internal fun getExpressErrorMessage(expressError: ExpressError): TextReference { + return when (expressError) { + is ExpressError.InternalError, + is ExpressError.Forbidden, + -> resourceReference( id = R.string.express_error_swap_unavailable, - formatArgs = wrappedList(expressDataError.code), + formatArgs = wrappedList(expressError.code), ) - is ExpressDataError.ExchangeNotPossibleError -> resourceReference( + is ExpressError.ExchangeNotPossibleError -> resourceReference( id = R.string.warning_express_pair_unavailable_message, - formatArgs = wrappedList(expressDataError.code), + formatArgs = wrappedList(expressError.code), ) - is ExpressDataError.UnknownError -> resourceReference(R.string.common_unknown_error) - is ExpressDataError.ExchangeProviderNotActiveError, - is ExpressDataError.ExchangeProviderNotFoundError, - is ExpressDataError.ExchangeProviderNotAvailableError, - is ExpressDataError.ExchangeProviderProviderInternalError, + is ExpressError.UnknownError -> resourceReference(R.string.common_unknown_error) + is ExpressError.ProviderNotActiveError, + is ExpressError.ProviderNotFoundError, + is ExpressError.ProviderNotAvailableError, + is ExpressError.ProviderInternalError, -> resourceReference( id = R.string.express_error_swap_pair_unavailable, - formatArgs = wrappedList(expressDataError.code), + formatArgs = wrappedList(expressError.code), ) - is ExpressDataError.ProviderDifferentAmountError -> resourceReference( + is ExpressError.ProviderDifferentAmountError -> resourceReference( R.string.express_error_provider_amount_roundup, formatArgs = wrappedList( - expressDataError.code, - expressDataError.fromProviderAmount.format { simple(decimals = expressDataError.decimals) }, + expressError.code, + expressError.fromProviderAmount.format { simple(decimals = expressError.decimals) }, ), ) - else -> resourceReference(R.string.express_error_code, wrappedList(expressDataError.code.toString())) + else -> resourceReference(R.string.express_error_code, wrappedList(expressError.code.toString())) } } -internal fun getExpressErrorTitle(expressDataError: ExpressDataError): TextReference { - return when (expressDataError) { - is ExpressDataError.ExchangeNotPossibleError -> resourceReference( +internal fun getExpressErrorTitle(expressError: ExpressError): TextReference { + return when (expressError) { + is ExpressError.ExchangeNotPossibleError -> resourceReference( id = R.string.warning_express_pair_unavailable_title, - formatArgs = wrappedList(expressDataError.code), + formatArgs = wrappedList(expressError.code), ) - is ExpressDataError.UnknownError -> resourceReference(R.string.common_error) + is ExpressError.UnknownError -> resourceReference(R.string.common_error) else -> resourceReference(R.string.warning_express_refresh_required_title) } } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt index 82ce65c84e..3f217d42d1 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/DefaultInitialCurrenciesResolverTest.kt @@ -231,6 +231,115 @@ internal class DefaultInitialCurrenciesResolverTest { assertThat(to).isNull() } + @Test + fun `GIVEN Token on ETH network with higher fiat vs Coin on BTC network with lower fiat WHEN no initial currency THEN Token is selected as FROM`() = + runTest { + val ethTokenId = mockCurrencyId(rawNetworkId = "ethereum", contractAddress = "0xUSDT") + val btcCoinId = mockCurrencyId(rawNetworkId = "bitcoin", contractAddress = "") + + val ethToken = mockk(relaxed = true) { + every { id } returns ethTokenId + } + val btcCoin = mockk(relaxed = true) { + every { id } returns btcCoinId + } + + val tokenStatus = createCurrencyStatus(ethToken, fiatAmount = BigDecimal("500")) + val coinStatus = createCurrencyStatus(btcCoin, fiatAmount = BigDecimal("100")) + + // currencies list order must match the order passed to setupAvailability + val accountStatus = createCryptoPortfolioAccountStatus(listOf(coinStatus, tokenStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(btcCoin to true, ethToken to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(tokenStatus) + assertThat(to).isNull() + } + + @Test + fun `GIVEN Token on ETH with highest fiat vs Coin on BTC with mid fiat vs Coin on SOL with lowest fiat WHEN no initial currency THEN Token is selected as FROM`() = + runTest { + val usdtId = mockCurrencyId(rawNetworkId = "ethereum", contractAddress = "0xUSDT") + val btcId = mockCurrencyId(rawNetworkId = "bitcoin", contractAddress = "") + val solId = mockCurrencyId(rawNetworkId = "solana", contractAddress = "") + + val usdtToken = mockk(relaxed = true) { + every { id } returns usdtId + } + val btcCoin = mockk(relaxed = true) { + every { id } returns btcId + } + val solCoin = mockk(relaxed = true) { + every { id } returns solId + } + + val usdtStatus = createCurrencyStatus(usdtToken, fiatAmount = BigDecimal("1000")) + val btcStatus = createCurrencyStatus(btcCoin, fiatAmount = BigDecimal("500")) + val solStatus = createCurrencyStatus(solCoin, fiatAmount = BigDecimal("100")) + + // list order: btcStatus, solStatus, usdtStatus → setupAvailability must match + val accountStatus = createCryptoPortfolioAccountStatus(listOf(btcStatus, solStatus, usdtStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(btcCoin to true, solCoin to true, usdtToken to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(usdtStatus) + assertThat(to).isNull() + } + + @Test + fun `GIVEN Coin on BTC with highest fiat vs Token on ETH with mid fiat vs Token on SOL with lowest fiat WHEN no initial currency THEN Coin is selected as FROM`() = + runTest { + val btcId = mockCurrencyId(rawNetworkId = "bitcoin", contractAddress = "") + val usdtId = mockCurrencyId(rawNetworkId = "ethereum", contractAddress = "0xUSDT") + val usdcId = mockCurrencyId(rawNetworkId = "solana", contractAddress = "EPjFWdd5") + + val btcCoin = mockk(relaxed = true) { + every { id } returns btcId + } + val usdtToken = mockk(relaxed = true) { + every { id } returns usdtId + } + val usdcToken = mockk(relaxed = true) { + every { id } returns usdcId + } + + val btcStatus = createCurrencyStatus(btcCoin, fiatAmount = BigDecimal("2000")) + val usdtStatus = createCurrencyStatus(usdtToken, fiatAmount = BigDecimal("600")) + val usdcStatus = createCurrencyStatus(usdcToken, fiatAmount = BigDecimal("150")) + + // list order: usdtStatus, usdcStatus, btcStatus → setupAvailability must match + val accountStatus = createCryptoPortfolioAccountStatus(listOf(usdtStatus, usdcStatus, btcStatus)) + setupSupplier(listOf(accountStatus)) + + setupAvailability(linkedMapOf(usdtToken to true, usdcToken to true, btcCoin to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(btcStatus) + assertThat(to).isNull() + } + // endregion // region initial currency tests @@ -747,6 +856,294 @@ internal class DefaultInitialCurrenciesResolverTest { // endregion + // region multi-account balance selection (no initial currency) + + @Test + fun `GIVEN two accounts where secondary has higher balance WHEN no initial currency THEN picks token from secondary account`() = + runTest { + // Main account: currency with low balance. + val mainCurrency = mockCryptoCurrency() + val mainStatus = createCurrencyStatus(mainCurrency, fiatAmount = BigDecimal("10")) + val mainAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(mainStatus), + derivationIndexValue = 0, + ) + + // Secondary account: currency with higher balance. + val secondaryCurrency = mockCryptoCurrency() + val secondaryStatus = createCurrencyStatus(secondaryCurrency, fiatAmount = BigDecimal("500")) + val secondaryAccount = createCryptoPortfolioAccountStatus( + currencies = listOf(secondaryStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(mainAccount, secondaryAccount)) + setupAvailability(linkedMapOf(mainCurrency to true)) + setupAvailability(linkedMapOf(secondaryCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // Global max-balance candidate lives in the secondary account. + assertThat(from?.status).isSameInstanceAs(secondaryStatus) + assertThat(to).isNull() + } + + @Test + fun `GIVEN two accounts each with several currencies where highest fiat token is in account 2 WHEN no initial currency THEN that token wins`() = + runTest { + val c1 = mockCryptoCurrency() + val c2 = mockCryptoCurrency() + val c3 = mockCryptoCurrency() + val c4 = mockCryptoCurrency() + + val s1 = createCurrencyStatus(c1, fiatAmount = BigDecimal("100")) + val s2 = createCurrencyStatus(c2, fiatAmount = BigDecimal("200")) + // c3 is a Token in account 2 with the highest fiat. + val s3 = createCurrencyStatus(c3, fiatAmount = BigDecimal("999")) + val s4 = createCurrencyStatus(c4, fiatAmount = BigDecimal("50")) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(s1, s2), + derivationIndexValue = 0, + ) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(s4, s3), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(c1 to true, c2 to true)) + setupAvailability(linkedMapOf(c4 to true, c3 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(s3) + assertThat(to).isNull() + } + + @Test + fun `GIVEN two accounts all balances are zero or null WHEN no initial currency THEN falls back to first currency of first account`() = + runTest { + val c1 = mockCryptoCurrency() + val c2 = mockCryptoCurrency() + + val s1 = createCurrencyStatus(c1, fiatAmount = BigDecimal.ZERO) + val s2 = createCurrencyStatus(c2, fiatAmount = null) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(s1), + derivationIndexValue = 0, + ) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(s2), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(c1 to true)) + setupAvailability(linkedMapOf(c2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // cryptoPortfolioAccountsMap.entries.firstOrNull()?.value?.firstOrNull() = s1. + assertThat(from?.status).isSameInstanceAs(s1) + assertThat(to).isNull() + } + + @Test + fun `GIVEN first account is empty and second account has currencies with balance WHEN no initial currency THEN picks highest balance from second account`() = + runTest { + val c1 = mockCryptoCurrency() + val c2 = mockCryptoCurrency() + + val s1 = createCurrencyStatus(c1, fiatAmount = BigDecimal("150")) + val s2 = createCurrencyStatus(c2, fiatAmount = BigDecimal("300")) + + // account1 has no currencies at all. + val account1 = createCryptoPortfolioAccountStatus( + currencies = emptyList(), + derivationIndexValue = 0, + ) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(s1, s2), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + // account1 is empty so rampStateManager is called with an empty list for it. + coEvery { rampStateManager.availableForSwap(userWalletId, emptyList()) } returns emptyMap() + setupAvailability(linkedMapOf(c1 to true, c2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = null, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + assertThat(from?.status).isSameInstanceAs(s2) + assertThat(to).isNull() + } + + // endregion + + // region initial currency + CurrencyPosition.ANY going to TO, scoped to same account + + @Test + fun `GIVEN selected zero-balance currency in account 1 goes to TO WHEN account 2 has higher-balance currency THEN FROM is picked only from account 1`() = + runTest { + // Account 1: the initial currency (available, zero balance → TO) + a companion. + val initialId = mockCurrencyId("ethereum", "0xDAI") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInAccount1 = mockCryptoCurrency(id = initialId) + val companion1 = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(initialInAccount1, fiatAmount = BigDecimal.ZERO) + val companionStatus = createCurrencyStatus(companion1, fiatAmount = BigDecimal("75")) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus, companionStatus), + derivationIndexValue = 0, + ) + + // Account 2: has a currency with much higher balance that must NOT be chosen as FROM. + val highBalanceCurrency = mockCryptoCurrency() + val highBalanceStatus = createCurrencyStatus(highBalanceCurrency, fiatAmount = BigDecimal("9999")) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(highBalanceStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(initialInAccount1 to true, companion1 to true)) + setupAvailability(linkedMapOf(highBalanceCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // FROM must be from account1, never the high-balance token from account2. + assertThat(from?.status).isSameInstanceAs(companionStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + @Test + fun `GIVEN selected zero-balance currency goes to TO WHEN same account has multiple currencies THEN FROM is the highest-balance one within same account`() = + runTest { + val initialId = mockCurrencyId("solana", "") + val initialCurrency = mockCryptoCurrency(id = initialId) + val initialInAccount = mockCryptoCurrency(id = initialId) + + val lowBalance = mockCryptoCurrency() + val midBalance = mockCryptoCurrency() + val highBalance = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(initialInAccount, fiatAmount = BigDecimal.ZERO) + val lowStatus = createCurrencyStatus(lowBalance, fiatAmount = BigDecimal("10")) + val midStatus = createCurrencyStatus(midBalance, fiatAmount = BigDecimal("100")) + val highStatus = createCurrencyStatus(highBalance, fiatAmount = BigDecimal("500")) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus, lowStatus, midStatus, highStatus), + derivationIndexValue = 0, + ) + + // Account 2 has an even higher balance that must NOT be picked. + val outsiderCurrency = mockCryptoCurrency() + val outsiderStatus = createCurrencyStatus(outsiderCurrency, fiatAmount = BigDecimal("10000")) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(outsiderStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(initialInAccount to true, lowBalance to true, midBalance to true, highBalance to true)) + setupAvailability(linkedMapOf(outsiderCurrency to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // FROM must be the highest-balance token within account 1 only. + assertThat(from?.status).isSameInstanceAs(highStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + // endregion + + // region isSameTokenAs dedupe across accounts + + @Test + fun `GIVEN same token in two accounts where selected is in account 1 WHEN going to TO THEN duplicate in account 2 is not considered and FROM is other account 1 currency`() = + runTest { + val sharedNetworkId = "polygon" + val sharedContract = "0xUSDC" + + // Same token in two accounts (different IDs due to different derivations). + val idInAccount1 = mockCurrencyId(sharedNetworkId, sharedContract) + val idInAccount2 = mockCurrencyId(sharedNetworkId, sharedContract) + + val initialCurrency = mockCryptoCurrency(id = idInAccount1) + val usdcInAccount1 = mockCryptoCurrency(id = idInAccount1) + val usdcInAccount2 = mockCryptoCurrency(id = idInAccount2) + + // Account 1 also has a distinct companion currency. + val account1Companion = mockCryptoCurrency() + + val initialStatus = createCurrencyStatus(usdcInAccount1, fiatAmount = BigDecimal.ZERO) + val companionStatus = createCurrencyStatus(account1Companion, fiatAmount = BigDecimal("200")) + + val account1 = createCryptoPortfolioAccountStatus( + currencies = listOf(initialStatus, companionStatus), + derivationIndexValue = 0, + ) + + // Account 2 has the duplicate token with a large balance — must NOT be picked. + val duplicateStatus = createCurrencyStatus(usdcInAccount2, fiatAmount = BigDecimal("5000")) + val account2 = createCryptoPortfolioAccountStatus( + currencies = listOf(duplicateStatus), + derivationIndexValue = 1, + ) + + setupSupplier(listOf(account1, account2)) + setupAvailability(linkedMapOf(usdcInAccount1 to true, account1Companion to true)) + setupAvailability(linkedMapOf(usdcInAccount2 to true)) + + val (from, to) = resolver.invoke( + userWalletId, + initialCryptoCurrency = initialCurrency, + swapCurrencyPosition = CurrencyPosition.ANY, + isPaymentAccount = false, + ) + + // FROM must be the account1 companion (scoped to account1, duplicate in account2 excluded). + assertThat(from?.status).isSameInstanceAs(companionStatus) + assertThat(to?.status).isSameInstanceAs(initialStatus) + } + + // endregion + // region helpers private fun mockCryptoCurrency( diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt new file mode 100644 index 0000000000..e8724ad601 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt @@ -0,0 +1,592 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.express.models.ExpressError +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.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.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +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 org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +internal class StateBuilderInitialStateTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val appCurrency = AppCurrency.Default + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns appCurrency + every { isAccountsModeProvider() } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + ) + } + + // region createInitialLoadingState (no-arg overload) + + @Nested + inner class `createInitialLoadingState no-arg` { + + @Test + fun `should return loading state with disabled swap button`() { + val result = sut.createInitialLoadingState() + + assertThat(result.swapButton.isEnabled).isFalse() + assertThat(result.swapButton.isInProgress).isTrue() + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `should return loading state with empty send and receive cards`() { + val result = sut.createInitialLoadingState() + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + assertThat(result.receiveCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + + @Test + fun `should return loading state with Empty fee`() { + val result = sut.createInitialLoadingState() + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `should return loading state with DISABLED changeCardsButtonState`() { + val result = sut.createInitialLoadingState() + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.DISABLED) + } + + @Test + fun `should return loading state with Empty providerState`() { + val result = sut.createInitialLoadingState() + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + + @Test + fun `should return loading state with null walletInteractionIcon`() { + val result = sut.createInitialLoadingState() + + assertThat(result.swapButton.walletInteractionIcon).isNull() + } + } + + // endregion + + // region createInitialReadyState + + @Nested + inner class CreateInitialReadyState { + + private val userWalletId = UserWalletId("aabbccdd") + private val userWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + private val baseState get() = sut.createInitialLoadingState() + + @Test + fun `GIVEN both currencies non-null WHEN called THEN sendCard is SwapCardData`() { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.SwapCardData::class.java) + } + + @Test + fun `GIVEN both currencies non-null WHEN called THEN receiveCard is SwapCardData`() { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.receiveCardData).isInstanceOf(SwapCardState.SwapCardData::class.java) + } + + @Test + fun `GIVEN fromCurrency is null WHEN called THEN sendCard is Empty`() { + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + + @Test + fun `GIVEN fromCurrency is null WHEN called THEN swapButton has no walletInteractionIcon`() { + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.walletInteractionIcon).isNull() + } + + @Test + fun `GIVEN cold wallet WHEN called THEN swapButton isHoldToConfirm is false`() { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `GIVEN hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + val fromStatus = buildSwapCurrencyStatus(hotWallet) + val toStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `WHEN called THEN changeCardsButtonState is ENABLED`() { + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `WHEN called THEN swapButton is disabled`() { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `WHEN called THEN providerState is Empty`() { + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + + @Test + fun `WHEN called THEN priceImpact is Empty`() { + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + ) + + assertThat(result.priceImpact).isEqualTo(PriceImpact.Empty) + } + + @Test + fun `WHEN called THEN notifications is empty`() { + val result = sut.createInitialReadyState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + ) + + assertThat(result.notifications).isEmpty() + } + } + + // endregion + + // region createInitialErrorState + + @Nested + inner class CreateInitialErrorState { + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val expressError: ExpressError = ExpressError.UnknownError + + private fun buildBaseStateWithSwapCardData(userWallet: UserWallet): SwapStateHolder { + val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + val loading = sut.createInitialLoadingState() + return sut.createInitialReadyState( + uiStateHolder = loading, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } + + @Test + fun `GIVEN fromSwapCurrencyStatus is null WHEN called THEN swapButton comes from uiStateHolder`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + val originalButton = baseState.swapButton + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.swapButton).isEqualTo(originalButton) + } + + @Test + fun `GIVEN fromSwapCurrencyStatus non-null with cold wallet WHEN called THEN swapButton isHoldToConfirm is false`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = fromStatus, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `GIVEN fromSwapCurrencyStatus non-null with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildBaseStateWithSwapCardData(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = fromStatus, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `WHEN called THEN swapButton is disabled`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = fromStatus, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `WHEN called THEN notifications contains ExpressErrorWarning`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = fromStatus, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Warning.ExpressErrorWarning::class.java) + } + + @Test + fun `WHEN called THEN permissionUM is Empty`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.permissionUM).isEqualTo(SwapPermissionUM.Empty) + } + + @Test + fun `WHEN called THEN fee is Empty`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `WHEN called THEN changeCardsButtonState is ENABLED`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `WHEN called THEN tosState is null`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + assertThat(result.tosState).isNull() + } + + @Test + fun `GIVEN sendCardData is SwapCardData with Inputtable type WHEN called THEN sendCard type becomes disabled`() { + val baseState = buildBaseStateWithSwapCardData(coldWallet) + + val result = sut.createInitialErrorState( + fromSwapCurrencyStatus = null, + uiStateHolder = baseState, + expressError = expressError, + onRetry = {}, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.isEnabled).isFalse() + } + } + + // endregion + + // region createInitialLoadingState (two-arg overload) + + @Nested + inner class `createInitialLoadingState two-arg overload` { + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + @Test + fun `GIVEN uiState has non-SwapCardData send card WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + // loadingState has SwapCardState.Empty cards — not SwapCardData + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createInitialLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = loadingState, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN uiState has SwapCardData cards WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() { + val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val readyState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.createInitialLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = readyState, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS) + } + + @Test + fun `GIVEN cold wallet WHEN called THEN swapButton isHoldToConfirm is false`() { + val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val readyState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.createInitialLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = readyState, + ) + + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `GIVEN hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + val toStatus = buildSwapCurrencyStatus(hotWallet) + // need a base state that has SwapCardData — use readyState built with hotWallet + val readyState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.createInitialLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = readyState, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + } + + // endregion +} + +// --- Helpers shared across StateBuilder test files --- + +internal fun buildSwapCurrencyStatus( + userWallet: UserWallet, +): SwapCurrencyStatus { + val userWalletId = userWallet.walletId + val account = Account.CryptoPortfolio.createMainAccount(userWalletId) + val currency: CryptoCurrency = mockk(relaxed = true) { + every { symbol } returns "ETH" + every { decimals } returns 18 + every { name } returns "Ethereum" + every { network } returns mockk(relaxed = true) { + every { id } returns mockk(relaxed = true) + every { name } returns "Ethereum" + every { currencySymbol } returns "ETH" + every { rawId } returns "ethereum" + } + } + val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) { + every { amount } returns java.math.BigDecimal("1.0") + every { fiatRate } returns java.math.BigDecimal("2000.00") + every { fiatAmount } returns java.math.BigDecimal("2000.00") + } + val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = currency, value = statusValue) + return SwapCurrencyStatus( + userWallet = userWallet, + status = cryptoCurrencyStatus, + account = account, + ) +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt new file mode 100644 index 0000000000..c1f52f0e5d --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt @@ -0,0 +1,408 @@ +package com.tangem.feature.swap + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.stringReference +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.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.domain.models.ui.SwapState +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 org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +internal class StateBuilderPairsTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference("$0.00"), + ) + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns AppCurrency.Default + every { isAccountsModeProvider() } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + ) + } + + // region createSwapNotSupportedState + + @Nested + inner class CreateSwapNotSupportedState { + + @Test + fun `GIVEN uiState sendCardData is not SwapCardData WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = loadingState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid SwapCardData state WHEN called THEN swapButton is disabled`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN valid SwapCardData state WHEN called THEN changeCardsButtonState is DISABLED`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.DISABLED) + } + + @Test + fun `GIVEN valid state WHEN called THEN notifications contain SwapNotSupported warning`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Warning.SwapNotSupported::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN providerState is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + + @Test + fun `GIVEN valid state with cold wallet WHEN called THEN swapButton isHoldToConfirm is false`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isFalse() + } + + @Test + fun `GIVEN valid state with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + val toStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `GIVEN valid state WHEN called THEN sendCardData type is ReadOnly`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createSwapNotSupportedState( + uiStateHolder = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.type).isInstanceOf(TransactionCardType.ReadOnly::class.java) + } + } + + // endregion + + // region updateCurrenciesState + + @Nested + inner class UpdateCurrenciesState { + + @Test + fun `GIVEN both currencies null WHEN called THEN sendCard is Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + + @Test + fun `GIVEN both currencies non-null WHEN called THEN sendCard is SwapCardData`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + shouldResetAmount = false, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.SwapCardData::class.java) + } + + @Test + fun `WHEN called THEN notifications is cleared`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.notifications).isEmpty() + } + + @Test + fun `WHEN called THEN isInsufficientFunds is false`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.isInsufficientFunds).isFalse() + } + + @Test + fun `WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `WHEN called THEN changeCardsButtonState is ENABLED`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `GIVEN hot wallet fromStatus WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `GIVEN toSwapCurrencyStatus is null WHEN called THEN sendCard isEnabled is false`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrenciesState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = null, + shouldResetAmount = false, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.isEnabled).isFalse() + } + } + + // endregion + + // region updateCurrencyBalanceStatus + + @Nested + inner class UpdateCurrencyBalanceStatus { + + @Test + fun `GIVEN balance hidden flag true WHEN called THEN sendCardData isBalanceHidden is true`() { + every { isBalanceHiddenProvider() } returns true + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrencyBalanceStatus( + uiState = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + emptyAmountState = emptyAmountState, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.isBalanceHidden).isTrue() + } + + @Test + fun `GIVEN balance hidden flag false WHEN called THEN sendCardData isBalanceHidden is false`() { + every { isBalanceHiddenProvider() } returns false + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateCurrencyBalanceStatus( + uiState = baseState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + emptyAmountState = emptyAmountState, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.isBalanceHidden).isFalse() + } + + @Test + fun `GIVEN fromStatus null WHEN called THEN sendCard becomes Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.updateCurrencyBalanceStatus( + uiState = baseState, + fromSwapCurrencyStatus = null, + toSwapCurrencyStatus = null, + emptyAmountState = emptyAmountState, + ) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + } + + // endregion + + // --- Helpers --- + + private fun buildReadyState(userWallet: UserWallet): SwapStateHolder { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + return sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt new file mode 100644 index 0000000000..e335298820 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt @@ -0,0 +1,844 @@ +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 +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.feature.swap.domain.models.ExpressDataError +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.domain.* +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 +import java.math.BigDecimal + +internal class StateBuilderQuotesTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns AppCurrency.Default + every { isAccountsModeProvider() } returns false + every { iGaslessFeeSupportedForNetwork(any()) } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + ) + } + + // region createQuotesLoadingState + + @Nested + inner class CreateQuotesLoadingState { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = loadingState, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid SwapCardData state WHEN called THEN providerState is Loading`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Loading::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS) + } + + @Test + fun `GIVEN valid state WHEN called THEN swapButton is disabled`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN valid state WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN notifications is cleared`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + val toStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `GIVEN valid state WHEN called THEN receiveCardData amountTextFieldValue is null`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.createQuotesLoadingState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + uiStateHolder = baseState, + ) + + val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData + assertThat(receiveCard?.amountTextFieldValue).isNull() + } + } + + // endregion + + // region createQuotesLoadedState + + @Nested + inner class CreateQuotesLoadedState { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = loadingState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid state with hideFee true WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = true, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state with hideFee false and single fee WHEN called THEN fee is Content`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel( + userWallet = coldWallet, + isBalanceEnough = true, + txFeeState = TxFeeState.SingleFeeState(fee = buildTxFeeLegacy(FeeType.NORMAL)), + ) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Content::class.java) + } + + @Test + fun `GIVEN valid state with sufficient balance WHEN called THEN isInsufficientFunds is false`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.isInsufficientFunds).isFalse() + } + + @Test + fun `GIVEN valid state with insufficient balance WHEN called THEN isInsufficientFunds is true`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel( + coldWallet, + isBalanceEnough = false, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + ) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.isInsufficientFunds).isTrue() + } + + @Test + fun `GIVEN valid state WHEN called THEN changeCardsButtonState is ENABLED`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `GIVEN valid state with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val quoteModel = buildQuoteModel(hotWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + + @Test + fun `GIVEN provider with termsOfUse WHEN called THEN tosState has tosLink`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider(termsOfUse = "https://example.com/tos") + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.tosState?.tosLink).isNotNull() + } + + @Test + fun `GIVEN provider without termsOfUse WHEN called THEN tosState has null tosLink`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider(termsOfUse = null) + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.tosState?.tosLink).isNull() + } + + @Test + fun `GIVEN no blocking notifications WHEN called THEN swapButton is enabled`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel(coldWallet, isBalanceEnough = true) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + assertThat(result.swapButton.isEnabled).isTrue() + } + + @Test + fun `GIVEN multiple fee state WHEN called THEN fee is Content with isClickable true`() { + val baseState = buildReadyState(coldWallet) + val quoteModel = buildQuoteModel( + userWallet = coldWallet, + isBalanceEnough = true, + txFeeState = TxFeeState.MultipleFeeState( + normalFee = buildTxFeeLegacy(FeeType.NORMAL), + priorityFee = buildTxFeeLegacy(FeeType.PRIORITY), + ), + ) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesLoadedState( + uiStateHolder = baseState, + quoteModel = quoteModel, + feeCryptoCurrencyStatus = null, + swapProvider = swapProvider, + bestRatedProviderId = "provider-id", + isNeedBestRateBadge = false, + selectedFeeType = FeeType.NORMAL, + needApplyFCARestrictions = false, + hideFee = false, + ) + + val feeContent = result.fee as? FeeItemState.Content + assertThat(feeContent?.isClickable).isTrue() + } + } + + // endregion + + // region createQuotesErrorState + + @Nested + inner class CreateQuotesErrorState { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = TokenSwapInfo( + tokenAmount = buildSwapAmount(), + amountFiat = BigDecimal.ZERO, + swapCurrencyStatus = fromStatus, + ) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = loadingState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid state WHEN called THEN swapButton is disabled`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN valid state WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN permissionUM is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.permissionUM).isEqualTo(SwapPermissionUM.Empty) + } + + @Test + fun `GIVEN toSwapCurrencyStatus null WHEN called THEN receiveCardData is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.receiveCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + + @Test + fun `GIVEN toSwapCurrencyStatus non-null WHEN called THEN receiveCardData is SwapCardData`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = toStatus, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.receiveCardData).isInstanceOf(SwapCardState.SwapCardData::class.java) + } + + @Test + fun `GIVEN ExchangeTooSmallAmountError WHEN called THEN providerState is Content`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.ExchangeTooSmallAmountError( + amount = buildSwapAmount(), + code = 100, + ), + needApplyFCARestrictions = false, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Content::class.java) + } + + @Test + fun `GIVEN UnknownError WHEN called THEN providerState is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val fromTokenInfo = buildTokenSwapInfo(fromStatus) + val swapProvider = buildSwapProvider() + + val result = sut.createQuotesErrorState( + uiStateHolder = baseState, + swapProvider = swapProvider, + fromToken = fromTokenInfo, + toSwapCurrencyStatus = null, + includeFeeInAmount = IncludeFeeInAmount.Excluded, + expressDataError = ExpressDataError.UnknownError, + needApplyFCARestrictions = false, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + } + + // endregion + + // region createQuotesEmptyAmountState + + @Nested + inner class CreateQuotesEmptyAmountState { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = loadingState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN valid SwapCardData state WHEN called THEN swapButton is disabled`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN valid state WHEN called THEN notifications is empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.notifications).isEmpty() + } + + @Test + fun `GIVEN valid state WHEN called THEN isInsufficientFunds is false`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.isInsufficientFunds).isFalse() + } + + @Test + fun `GIVEN valid state WHEN called THEN fee is Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.fee).isInstanceOf(FeeItemState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN changeCardsButtonState is ENABLED`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.ENABLED) + } + + @Test + fun `GIVEN valid state WHEN called THEN providerState is Empty`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + assertThat(result.providerState).isInstanceOf(ProviderState.Empty::class.java) + } + + @Test + fun `GIVEN valid state WHEN called THEN receiveCard amountTextFieldValue is 0`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = null, + ) + + val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData + assertThat(receiveCard?.amountTextFieldValue?.text).isEqualTo("0") + } + + @Test + fun `GIVEN fromSwapCurrencyStatus with hot wallet WHEN called THEN swapButton isHoldToConfirm is true`() { + val baseState = buildReadyState(hotWallet) + val fromStatus = buildSwapCurrencyStatus(hotWallet) + + val result = sut.createQuotesEmptyAmountState( + uiStateHolder = baseState, + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + ) + + assertThat(result.swapButton.isHoldToConfirm).isTrue() + } + } + + // endregion + + // --- Helpers --- + + private fun buildReadyState(userWallet: UserWallet): SwapStateHolder { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + return sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } + + private fun buildQuoteModel( + userWallet: UserWallet, + isBalanceEnough: Boolean, + includeFeeInAmount: IncludeFeeInAmount = IncludeFeeInAmount.Excluded, + txFeeState: TxFeeState = TxFeeState.Empty, + ): SwapState.QuotesLoadedState { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + + val fromTokenInfo = TokenSwapInfo( + tokenAmount = buildSwapAmount(), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = fromStatus, + ) + val toTokenInfo = TokenSwapInfo( + tokenAmount = buildSwapAmount(value = BigDecimal("0.05")), + amountFiat = BigDecimal("100.00"), + swapCurrencyStatus = toStatus, + ) + + return SwapState.QuotesLoadedState( + fromTokenInfo = fromTokenInfo, + toTokenInfo = toTokenInfo, + priceImpact = PriceImpact.Empty, + preparedSwapConfigState = PreparedSwapConfigState( + isBalanceEnough = isBalanceEnough, + feeState = SwapFeeState.Enough, + hasOutgoingTransaction = false, + includeFeeInAmount = includeFeeInAmount, + ), + permissionState = PermissionDataState.Empty, + txFee = txFeeState, + currencyCheck = null, + validationResult = null, + minAdaValue = null, + swapProvider = buildSwapProvider(), + ) + } + + private fun buildSwapProvider( + termsOfUse: String? = null, + privacyPolicy: String? = null, + ) = SwapProvider( + providerId = "provider-id", + name = "TestProvider", + type = ExchangeProviderType.DEX, + imageLarge = "https://example.com/icon.png", + termsOfUse = termsOfUse, + privacyPolicy = privacyPolicy, + isRecommended = false, + slippage = null, + ) + + private fun buildSwapAmount(value: BigDecimal = BigDecimal("1.0")) = SwapAmount( + value = value, + decimals = 18, + ) + + private fun buildTokenSwapInfo(swapCurrencyStatus: SwapCurrencyStatus) = TokenSwapInfo( + tokenAmount = buildSwapAmount(), + amountFiat = BigDecimal.ZERO, + swapCurrencyStatus = swapCurrencyStatus, + ) + + private fun buildTxFeeLegacy(feeType: FeeType): TxFee.Legacy { + val fee: com.tangem.blockchain.common.transaction.Fee = mockk(relaxed = true) + return TxFee.Legacy( + feeValue = BigDecimal("0.001"), + feeFiatFormatted = "$2.00", + feeCryptoFormatted = "0.001 ETH", + feeIncludeOtherNativeFee = BigDecimal.ZERO, + feeFiatFormattedWithNative = "$2.00", + feeCryptoFormattedWithNative = "0.001 ETH", + cryptoSymbol = "ETH", + feeType = feeType, + fee = fee, + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt new file mode 100644 index 0000000000..ed55977ec8 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt @@ -0,0 +1,603 @@ +package com.tangem.feature.swap + +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 +import com.tangem.utils.Provider +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class StateBuilderSwapDataTest { + + private val actions: UiActions = mockk(relaxed = true) + private val isBalanceHiddenProvider: Provider = mockk() + private val appCurrencyProvider: Provider = mockk() + private val isAccountsModeProvider: Provider = mockk() + private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + + private lateinit var sut: StateBuilder + + private val userWalletId = UserWalletId("aabbccdd") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + private val hotWallet: UserWallet.Hot = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + private val emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"), + ) + + @BeforeEach + fun setup() { + every { isBalanceHiddenProvider() } returns false + every { appCurrencyProvider() } returns AppCurrency.Default + every { isAccountsModeProvider() } returns false + every { iGaslessFeeSupportedForNetwork(any()) } returns false + + sut = StateBuilder( + actions = actions, + isBalanceHiddenProvider = isBalanceHiddenProvider, + appCurrencyProvider = appCurrencyProvider, + isAccountsModeProvider = isAccountsModeProvider, + iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + ) + } + + // region createSwapInProgressState + + @Nested + inner class CreateSwapInProgressState { + + @Test + fun `WHEN called THEN swapButton isInProgress becomes true`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createSwapInProgressState(baseState) + + assertThat(result.swapButton.isInProgress).isTrue() + } + + @Test + fun `WHEN called THEN swapButton isEnabled becomes false`() { + val baseState = buildReadyState(coldWallet) + // force enable the button by overriding manually + val stateWithEnabled = baseState.copy( + swapButton = baseState.swapButton.copy(isEnabled = true), + ) + + val result = sut.createSwapInProgressState(stateWithEnabled) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `WHEN called THEN all other fields remain unchanged`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createSwapInProgressState(baseState) + + assertThat(result.sendCardData).isEqualTo(baseState.sendCardData) + assertThat(result.receiveCardData).isEqualTo(baseState.receiveCardData) + assertThat(result.fee).isEqualTo(baseState.fee) + assertThat(result.changeCardsButtonState).isEqualTo(baseState.changeCardsButtonState) + } + } + + // endregion + + // region createSilentLoadState + + @Nested + inner class CreateSilentLoadState { + + @Test + fun `WHEN called THEN changeCardsButtonState is UPDATE_IN_PROGRESS`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.createSilentLoadState(baseState) + + assertThat(result.changeCardsButtonState).isEqualTo(ChangeCardsButtonState.UPDATE_IN_PROGRESS) + } + + @Test + fun `GIVEN notifications without PermissionNeeded WHEN called THEN notifications remain unchanged`() { + val errorNotification = SwapNotificationUM.Warning.SwapNotSupported + val baseState = buildReadyState(coldWallet).copy( + notifications = persistentListOf(errorNotification), + ) + + val result = sut.createSilentLoadState(baseState) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isEqualTo(errorNotification) + } + + @Test + fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is removed`() { + val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded( + providerName = "TestProvider", + fromTokenSymbol = "ETH", + onApproveClick = {}, + ) + val otherNotification = SwapNotificationUM.Warning.SwapNotSupported + val baseState = buildReadyState(coldWallet).copy( + notifications = listOf(permissionNeeded, otherNotification).toImmutableList(), + ) + + val result = sut.createSilentLoadState(baseState) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isEqualTo(otherNotification) + } + } + + // endregion + + // region updateSwapAmount + + @Nested + inner class UpdateSwapAmount { + + @Test + fun `GIVEN uiState has Empty sendCard WHEN called THEN returns uiState unchanged`() { + val loadingState = sut.createInitialLoadingState() + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = loadingState, + amountFormatted = "1.5", + amountRaw = "1.5", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = null, + ) + + assertThat(result).isSameInstanceAs(loadingState) + } + + @Test + fun `GIVEN amount is above minTxAmount WHEN called THEN inputError is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = baseState, + amountFormatted = "2.0", + amountRaw = "2.0", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = BigDecimal("1.0"), + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.Empty) + } + + @Test + fun `GIVEN amount is below minTxAmount WHEN called THEN inputError is WrongAmount`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = baseState, + amountFormatted = "0.5", + amountRaw = "0.5", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = BigDecimal("1.0"), + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.WrongAmount) + } + + @Test + fun `GIVEN minTxAmount is null WHEN called THEN inputError is Empty`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = baseState, + amountFormatted = "0.001", + amountRaw = "0.001", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = null, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val inputtable = sendCard?.type as? TransactionCardType.Inputtable + assertThat(inputtable?.inputError).isEqualTo(TransactionCardType.InputError.Empty) + } + + @Test + fun `WHEN called THEN sendCardData amountTextFieldValue text is updated`() { + val baseState = buildReadyState(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + + val result = sut.updateSwapAmount( + uiState = baseState, + amountFormatted = "3.14", + amountRaw = "3.14", + fromSwapCurrencyStatus = fromStatus, + minTxAmount = null, + ) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.amountTextFieldValue?.text).isEqualTo("3.14") + } + } + + // endregion + + // region updateBalanceHiddenState + + @Nested + inner class UpdateBalanceHiddenState { + + @Test + fun `GIVEN isBalanceHidden true WHEN called THEN sendCardData isBalanceHidden is true`() { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val baseState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = true) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.isBalanceHidden).isTrue() + } + + @Test + fun `GIVEN isBalanceHidden true WHEN called THEN receiveCardData isBalanceHidden is true`() { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val baseState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = true) + + val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData + assertThat(receiveCard?.isBalanceHidden).isTrue() + } + + @Test + fun `GIVEN isBalanceHidden false WHEN called THEN both cards isBalanceHidden is false`() { + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val baseState = sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + + val result = sut.updateBalanceHiddenState(baseState, isBalanceHidden = false) + + val sendCard = result.sendCardData as? SwapCardState.SwapCardData + val receiveCard = result.receiveCardData as? SwapCardState.SwapCardData + assertThat(sendCard?.isBalanceHidden).isFalse() + assertThat(receiveCard?.isBalanceHidden).isFalse() + } + + @Test + fun `GIVEN sendCard is Empty type WHEN called THEN sendCard remains Empty type`() { + val loadingState = sut.createInitialLoadingState() + + val result = sut.updateBalanceHiddenState(loadingState, isBalanceHidden = true) + + assertThat(result.sendCardData).isInstanceOf(SwapCardState.Empty::class.java) + } + } + + // endregion + + // region loadingPermissionState + + @Nested + inner class LoadingPermissionState { + + @Test + fun `WHEN called THEN swapButton isEnabled is false`() { + val baseState = buildReadyState(coldWallet).copy( + swapButton = buildReadyState(coldWallet).swapButton.copy(isEnabled = true), + ) + + val result = sut.loadingPermissionState(baseState) + + assertThat(result.swapButton.isEnabled).isFalse() + } + + @Test + fun `WHEN called THEN swapButton isInProgress is false`() { + val baseState = buildReadyState(coldWallet).copy( + swapButton = buildReadyState(coldWallet).swapButton.copy(isInProgress = true), + ) + + val result = sut.loadingPermissionState(baseState) + + assertThat(result.swapButton.isInProgress).isFalse() + } + + @Test + fun `GIVEN notifications without PermissionNeeded WHEN called THEN ApprovalInProgressWarning is prepended`() { + val existingNotification = SwapNotificationUM.Warning.SwapNotSupported + val baseState = buildReadyState(coldWallet).copy( + notifications = persistentListOf(existingNotification), + ) + + val result = sut.loadingPermissionState(baseState) + + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.ApprovalInProgressWarning::class.java) + } + + @Test + fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is replaced by ApprovalInProgressWarning`() { + val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded( + providerName = "TestProvider", + fromTokenSymbol = "ETH", + onApproveClick = {}, + ) + val baseState = buildReadyState(coldWallet).copy( + notifications = persistentListOf(permissionNeeded), + ) + + val result = sut.loadingPermissionState(baseState) + + assertThat(result.notifications).doesNotContain(permissionNeeded) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.ApprovalInProgressWarning::class.java) + } + } + + // endregion + + // region dismissBottomSheet + + @Nested + inner class DismissBottomSheet { + + @Test + fun `GIVEN bottomSheetConfig is null WHEN called THEN bottomSheetConfig remains null`() { + val baseState = buildReadyState(coldWallet) + assertThat(baseState.bottomSheetConfig).isNull() + + val result = sut.dismissBottomSheet(baseState) + + assertThat(result.bottomSheetConfig).isNull() + } + + @Test + fun `GIVEN bottomSheetConfig is shown WHEN called THEN bottomSheetConfig isShown becomes false`() { + val baseState = buildReadyState(coldWallet).copy( + bottomSheetConfig = com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig( + isShown = true, + onDismissRequest = {}, + content = mockk(relaxed = true), + ), + ) + + val result = sut.dismissBottomSheet(baseState) + + assertThat(result.bottomSheetConfig?.isShown).isFalse() + } + } + + // endregion + + // region addNotification + + @Nested + inner class AddNotification { + + @Test + fun `GIVEN a message WHEN called THEN notifications contains GenericError`() { + val baseState = buildReadyState(coldWallet) + val message = com.tangem.core.ui.extensions.stringReference("Something went wrong") + + val result = sut.addNotification( + uiState = baseState, + message = message, + onClick = {}, + ) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.GenericError::class.java) + } + + @Test + fun `GIVEN null message WHEN called THEN notifications contains GenericError`() { + val baseState = buildReadyState(coldWallet) + + val result = sut.addNotification( + uiState = baseState, + message = null, + onClick = {}, + ) + + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications[0]).isInstanceOf(SwapNotificationUM.Error.GenericError::class.java) + } + } + + // endregion + + // region createSuccessState + + @Nested + inner class CreateSuccessState { + + @Test + fun `GIVEN valid state WHEN called THEN successState is not null`() { + val baseState = buildReadyStateWithContentProvider(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + selectedFee = null, + ) + val swapTransactionState = buildSwapTransactionState() + + val result = sut.createSuccessState( + uiState = baseState, + swapTransactionState = swapTransactionState, + dataState = dataState, + onExploreClick = {}, + onStatusClick = {}, + txUrl = "https://example.com/tx/abc", + ) + + assertThat(result.successState).isNotNull() + } + + @Test + fun `GIVEN CEX provider WHEN called THEN shouldShowStatusButton is true`() { + val baseState = buildReadyStateWithContentProvider( + coldWallet, + providerType = ExchangeProviderType.CEX, + ) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + selectedFee = null, + ) + val swapTransactionState = buildSwapTransactionState() + + val result = sut.createSuccessState( + uiState = baseState, + swapTransactionState = swapTransactionState, + dataState = dataState, + onExploreClick = {}, + onStatusClick = {}, + txUrl = "https://example.com/tx/abc", + ) + + assertThat(result.successState?.shouldShowStatusButton).isTrue() + } + + @Test + fun `GIVEN DEX provider WHEN called THEN shouldShowStatusButton is false`() { + val baseState = buildReadyStateWithContentProvider( + coldWallet, + providerType = ExchangeProviderType.DEX, + ) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + selectedFee = null, + ) + val swapTransactionState = buildSwapTransactionState() + + val result = sut.createSuccessState( + uiState = baseState, + swapTransactionState = swapTransactionState, + dataState = dataState, + onExploreClick = {}, + onStatusClick = {}, + txUrl = "https://example.com/tx/abc", + ) + + assertThat(result.successState?.shouldShowStatusButton).isFalse() + } + + @Test + fun `GIVEN txUrl WHEN called THEN successState txUrl matches`() { + val baseState = buildReadyStateWithContentProvider(coldWallet) + val fromStatus = buildSwapCurrencyStatus(coldWallet) + val toStatus = buildSwapCurrencyStatus(coldWallet) + val dataState = SwapProcessDataState( + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + selectedFee = null, + ) + val swapTransactionState = buildSwapTransactionState() + val expectedUrl = "https://etherscan.io/tx/0xabc" + + val result = sut.createSuccessState( + uiState = baseState, + swapTransactionState = swapTransactionState, + dataState = dataState, + onExploreClick = {}, + onStatusClick = {}, + txUrl = expectedUrl, + ) + + assertThat(result.successState?.txUrl).isEqualTo(expectedUrl) + } + } + + // endregion + + // --- Helpers --- + + private fun buildReadyState(userWallet: UserWallet): SwapStateHolder { + val fromStatus = buildSwapCurrencyStatus(userWallet) + val toStatus = buildSwapCurrencyStatus(userWallet) + return sut.createInitialReadyState( + uiStateHolder = sut.createInitialLoadingState(), + emptyAmountState = emptyAmountState, + fromSwapCurrencyStatus = fromStatus, + toSwapCurrencyStatus = toStatus, + ) + } + + private fun buildReadyStateWithContentProvider( + userWallet: UserWallet, + providerType: ExchangeProviderType = ExchangeProviderType.DEX, + ): SwapStateHolder { + val baseState = buildReadyState(userWallet) + return baseState.copy( + providerState = ProviderState.Content( + id = "provider-id", + name = "TestProvider", + type = providerType.providerName, + iconUrl = "https://example.com/icon.png", + subtitle = com.tangem.core.ui.extensions.stringReference("1 ETH ≈ 2000 USDT"), + additionalBadge = ProviderState.AdditionalBadge.Empty, + selectionType = ProviderState.SelectionType.CLICK, + namePrefix = ProviderState.PrefixType.NONE, + onProviderClick = {}, + ), + ) + } + + private fun buildSwapTransactionState(): SwapTransactionState.TxSent { + return SwapTransactionState.TxSent( + fromAmount = "1.0 ETH", + toAmount = "2000 USDT", + fromAmountValue = BigDecimal("1.0"), + toAmountValue = BigDecimal("2000"), + txHash = "0xabc", + timestamp = System.currentTimeMillis(), + ) + } +} \ No newline at end of file From 161e584fac32822fb2764de1edd6bd31c10032b9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 10:47:07 +0300 Subject: [PATCH 157/206] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 3 --- core/res/src/main/res/values-es/strings.xml | 3 --- core/res/src/main/res/values-fr/strings.xml | 3 --- core/res/src/main/res/values-it/strings.xml | 3 --- core/res/src/main/res/values-ja/strings.xml | 14 +++++++++----- core/res/src/main/res/values-pt-rBR/strings.xml | 3 --- core/res/src/main/res/values-ru/strings.xml | 10 ++++++---- core/res/src/main/res/values-uk-rUA/strings.xml | 3 --- core/res/src/main/res/values-zh-rCN/strings.xml | 3 --- core/res/src/main/res/values-zh-rTW/strings.xml | 3 --- core/res/src/main/res/values/strings.xml | 4 ++++ .../model/StakingModelTransactionTest.kt | 8 +++++++- 12 files changed, 26 insertions(+), 34 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index a67d74a936..41c4f24cdb 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -150,9 +150,6 @@ Nicht mehr anzeigen Verstanden Guthaben sind ausgeblendet - Swap starten - Tauschen Sie ein Asset gegen ein anderes – in wenigen Schritten - Führen Sie Ihren ersten Swap durch Laut den Blockchain-Entwicklern befinden sich der Kaspa-Token derzeit in der Betaphase. Bleibe dran für Updates! Beta-Phase Die Biometrie ist auf Deinem Gerät deaktiviert, daher kannst Du sie nicht zum Entsperren Deiner Wallets verwenden. Aktiviere die Biometrie in den Geräteeinstellungen, um diese Methode wieder nutzen zu können. diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 9fd0d37f78..f7bc36a8d3 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -146,9 +146,6 @@ No mostrar de nuevo Entendido Los saldos están ocultos - Iniciar intercambio - Convierte un activo en otro con solo unos toques - Realiza tu primer intercambio Según los desarrolladores de la blockchain, los tokens de Kaspa se encuentran actualmente en fase beta. ¡Estén atentos a las actualizaciones! Modo Beta La biometría está desactivada en su dispositivo, por lo que no puede utilizarla para desbloquear sus billeteras. Active la biometría en los ajustes de su dispositivo para volver a utilizar este método. diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index a56d16083d..8faa21a926 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -146,9 +146,6 @@ Ne plus afficher Compris Les soldes sont masqués - Lancer l\'échange - Convertissez un actif en un autre en quelques touches - Effectuez votre premier échange Selon les développeurs de la blockchain, les jetons Kaspa sont actuellement en version bêta. Restez à l\'écoute des mises à jour ! Mode bêta La biométrie est désactivée sur votre appareil, vous ne pouvez donc pas l\'utiliser pour déverrouiller vos portefeuilles. Activez la biométrie dans les paramètres de votre appareil pour pouvoir à nouveau utiliser cette méthode. diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 5291fdefb6..1b7dd158fb 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -3,9 +3,6 @@ Default Legacy Questa carta non è progettata per funzionare con Tangem - Avvia lo scambio - Converti un asset in un altro con pochi tocchi - Esegui il tuo primo scambio L\'importo inviato e il cambio non può essere inferiore a 1 ADA Accetta Importo diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index cefc401934..b500909414 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -149,9 +149,6 @@ 今後表示しない わかりました 残高は非表示 - スワップを開始 - 数タップで1つの資産を別の資産に交換できます - はじめてのスワップ ブロックチェーン開発者によると、Kaspaトークンは現在ベータ版です。アップデートをお楽しみに! ベータモード この端末では生体認証がオフになっているため、ウォレットの解除に使用できません。再度この方法を使用するには、端末の設定で生体認証を有効にしてください。 @@ -510,6 +507,7 @@ 追加のアドレスに資金が見つかりました。アクセスするには、動的アドレスを有効にしてください。 追加のアドレスで資金が見つかりました 動的アドレス + ネットワーク%@で保留中の取引が完了すると、ダイナミックアドレスを管理できるようになります。 おすすめ 絞り込みを解除 リストは現在更新中のため、一時的に空になっています。しばらくしてからご確認ください。 @@ -1099,6 +1097,12 @@ この取引はすでに処理されています。これ以上の対応は必要ありません。 最良のレートを取得しています... 即時 + 本人確認は無料で、通常1〜2分で完了します。 + Tangemが本人確認情報にアクセスすることはありません。お客様の情報は、規制対象のプロバイダーに直接共有されます。 + 本人確認を行うと、今後このプロバイダーでの取引をすべて利用できるようになります。 + 別の方法を選択してください。 + 現地の規制要件に準拠するため、%@の利用には本人確認が必要です。 + 決済プロバイダーによる本人確認が必要です。 オンランプ機能を使用することにより、プロバイダの%1$sおよび%2$sに同意するものとします サービスは外部プロバイダーによって提供されます。 \nTangemは責任を負いません。 買付金額は%s以下にしてください @@ -1591,7 +1595,7 @@ 保留中 取消済み 利用規約・手数料・利用制限 - 利用規約と上限条件 + 利用規約と手数料 銀行がこの取引リクエストを拒否しました。 この手数料は、送金処理にかかるコストをカバーするためのものです。 この取引は加盟店により一部または全額取り消されました @@ -1731,7 +1735,7 @@ Tangem Pay Polygonネットワーク上のUSDC 下のボタンをクリックしてアクセスを復元してください - USDC Polygonのオンチェーン残高はカード残高とは異なり、購入後2営業日以内に更新されます。返金された購入資金はオンチェーン残高に戻らず、出金もできませんが、カード残高に残り、購入に使用できます。 + 返金分はオンチェーンのPolygon残高には戻らず、出金にも利用できません。ただし、カード残高として残り、支払いに利用できます。 ご注意ください PINコード これは私のウォレットです diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 7a017e2a39..45dbb43d03 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -150,9 +150,6 @@ Não mostrar novamente Entendi Os saldos estão ocultos. - Iniciar swap - Converta um ativo em outro com apenas alguns toques - Faça seu primeiro swap Segundo os desenvolvedores da blockchain, os tokens Kaspa estão atualmente em fase beta. Fique atento para mais novidades! Modo Beta A biometria está desativada no seu dispositivo, portanto, você não pode usá-la para desbloquear suas carteiras. Ative a biometria nas configurações do seu dispositivo para usar esse método novamente. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 6d011848ae..0a6290a4fb 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -152,9 +152,6 @@ Больше не показывать Понятно Балансы скрыты - Начать обмен - Превратите один актив в другой всего за несколько касаний - Совершите первый обмен Согласно информации от разработчиков сети, токены Kaspa находятся в режиме бета. Следите за обновлениями! Бета режим Биометрия отключена на вашем устройстве, поэтому вы не можете использовать её для разблокировки кошельков. Включите биометрию в настройках устройства, чтобы снова использовать этот способ. @@ -228,6 +225,7 @@ Аккаунты Активировать Добавить + Добавить средств Добавить в портфель Добавить токен Добавьте токены @@ -1200,6 +1198,7 @@ Поддерживаемые токены не найдены Этот QR-код содержит параметры, которые не распознаны: %s. Некоторые данные платежа могут быть утеряны, если вы продолжите. Неизвестные параметры + Поделиться адресом или QR кодом Memo не требуется %1$s (%2$s) в сети %3$s %1$s в %2$s сети @@ -1586,7 +1585,9 @@ Подтверждения считаются отраслевым стандартом для всех децентрализованных бирж и защищают ваш кошелек от доступа со стороны смарт-контракта без вашего разрешения. По замыслу смарт-контракты не могут получить доступ к вашим токенам, если вы не одобрите доступ со своей стороны. «Разблокируя» свои токены, вы даете смарт-контракту 1inch разрешение тратить ваши активы. Майнеры сети получают компенсацию за газ (оплачиваемый вами) за запись этого действия в блокчейне. Как только разрешение будет предоставлено, вы сможете обменять свой токен. Разрешение Ошибка расчета комиссии. Пожалуйста, отправьте информацию в поддержку. + Вы отправляете с Вы отправляете + Вы отправляете Обмен этой суммы выбранных токенов может вызвать значительные колебания цены и уменьшить получаемую сумму. Вы можете получить значительно меньше из-за низкой ликвидности. Попробуйте меньшую сумму или другого провайдера. Высокое влияние на цену @@ -1595,6 +1596,7 @@ Дать разрешение Обменять Обмен… + Вы получите на Вы получите Выберите токен недоступен @@ -1685,7 +1687,7 @@ Вывод сейчас недоступен Вы не можете начать обмен или новый вывод, пока не завершится текущий. Вывод выполняется - Установить лимит от %s + Можно установить от %s Не удалось установить лимит. Пожалуйста, попробуйте снова. Изменить Текущий лимит diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index ec58da954a..0e1fecd93d 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -148,9 +148,6 @@ Більше не показувати Зрозуміло Баланси приховані - Почати обмін - Перетворіть один актив на інший лише кількома дотиками - Здійсніть перший обмін Згідно інформації від розробників мережі, токени Kaspa знаходяться у режимі бета. Слідкуйте за оновленнями! Бета режим Біометрія на вашому пристрої вимкнена, тому ви не можете використовувати її для розблокування гаманців. Увімкніть біометрію в налаштуваннях пристрою, щоб знову використовувати цей метод. diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 4cdf3bc127..336648fd49 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -149,9 +149,6 @@ 不要再次显示 明白 余额已隐藏 - 开始兑换 - 完成您的首次兑换 - 完成您的首次兑换 据区块链开发者称,Kaspa代币目前处于测试阶段。敬请关注后续更新! 测试模式 您的设备已关闭生物识别功能,因此无法使用此功能解锁钱包。请在设备设置中启用生物识别功能,即可再次使用此方法。 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index 5cc5b00128..69ae0df956 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -22,9 +22,6 @@ 將錢包保存在應用程序中 啟用以將所有錢包鏈接到 Tangem 應用程序。解鎖應用程序需要生物識別身份驗證。交易簽名需要輕觸您的 Tangem 卡片 APP設置 - 開始兌換 - 完成您的首次兌換 - 完成您的首次兌換 請掃描卡片 請30秒後重試或刷卡 嘗試次數過多 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 40a4a3dba0..9e3feaa6e5 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1123,6 +1123,8 @@ Choose another method To comply with local regulatory requirements %@ requires identity verification. Identity verification required by payment provider + Verify + What\'s important By using onramp functionality, you agree with provider’s %1$s and %2$s Service is provided by an external provider.\nTangem is not responsible. The purchase amount should be no more than %s @@ -1189,6 +1191,8 @@ Credit card or bank account Share your address or QR-code Between your portfolios + Other + Quick top up No memo required %1$s (%2$s) on %3$s network %1$s on %2$s network diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt index 2a40d90554..7e192c45b1 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt @@ -654,6 +654,7 @@ internal class StakingModelTransactionTest : StakingModelTestBase() { fun `WHEN onNotEnoughFeeNotificationShow THEN NotEnoughFee analytics sent with token`() = runTest { val (testCryptoCurrencyStatus, testAccountCurrencyStatus) = createMockedAccountCurrencyStatus() every { testCryptoCurrencyStatus.currency.symbol } returns "SOL" + every { testCryptoCurrencyStatus.currency.network.name } returns "solana" every { getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) } returns flowOf(testAccountCurrencyStatus) @@ -670,7 +671,12 @@ internal class StakingModelTransactionTest : StakingModelTestBase() { model.onNotEnoughFeeNotificationShow() verify { - analyticsEventHandler.send(StakingAnalyticsEvent.NotEnoughFee(token = "SOL")) + analyticsEventHandler.send( + StakingAnalyticsEvent.NotEnoughFee( + token = "SOL", + blockchain = "solana", + ) + ) } model.onDestroy() From 14178e902786f1ec2e747cccf01c69f84847986c Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 14:22:49 +0500 Subject: [PATCH 158/206] Updated on 2026-08-14 --- .../api/choosetoken/ChooseTokenBridge.kt | 6 +- .../choosetoken/model/ChooseTokenModel.kt | 3 +- .../model/PortfolioListBlockDelegate.kt | 1 + .../feature/swap/domain/SwapInteractorImpl.kt | 4 +- .../feature/swap/analytics/SwapEvents.kt | 38 +++++++++-- .../tangem/feature/swap/model/SwapModel.kt | 67 ++++++++++++++++--- 6 files changed, 100 insertions(+), 19 deletions(-) diff --git a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt index 596f8564aa..b581a64908 100644 --- a/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt +++ b/features/common-features/api/src/main/java/com/tangem/features/commonfeatures/api/choosetoken/ChooseTokenBridge.kt @@ -6,8 +6,8 @@ import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM import com.tangem.features.commonfeatures.api.R +import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -95,4 +95,8 @@ sealed interface ChooseTokenAnalyticsPayload { @JvmInline value class ScreensSources(val value: String) : ChooseTokenAnalyticsPayload + + @Suppress("BooleanPropertyNaming") + @JvmInline + value class IsMarketTokenSelected(val value: Boolean) : ChooseTokenAnalyticsPayload } \ No newline at end of file diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt index 4687184b49..97810d1fda 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/ChooseTokenModel.kt @@ -80,11 +80,12 @@ internal class ChooseTokenModel @Inject constructor( addToPortfolioManager.onSuccessAdded.receiveAsFlow() .onEach { addedResult -> val isSearched = ChooseTokenAnalyticsPayload.IsSearched(isSearchingState) + val isMarketToken = ChooseTokenAnalyticsPayload.IsMarketTokenSelected(true) val chooseTokenResult = ChooseTokenResult( currency = addedResult.addedCurrency, account = addedResult.account, wallet = addedResult.wallet, - analyticsPayload = setOf(isSearched), + analyticsPayload = setOf(isSearched, isMarketToken), ) bridge.onCurrencyChosen(chooseTokenResult) marketBlockDelegate.addToPortfolioSlot.dismiss() diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt index f2acbf62c8..8a8420567e 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt @@ -112,6 +112,7 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( private fun onTokenItemClick(wallet: UserWallet, account: AccountStatus, currencyStatus: CryptoCurrencyStatus) { val analyticsPayload = setOf( ChooseTokenAnalyticsPayload.IsSearched(searchQueryState.isSearchingState), + ChooseTokenAnalyticsPayload.IsMarketTokenSelected(false), ) val result = ChooseTokenResult( account = account, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 35e943bda2..9fc28e116c 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -159,8 +159,8 @@ internal class SwapInteractorImpl @Inject constructor( return pairs.firstOrNull { pair -> pair.from.network == fromSwapCurrencyStatus.currency.network.rawId && pair.from.contractAddress == fromSwapCurrencyStatus.currency.getContractAddress() && - pair.to.network == toSwapCurrencyStatus.currency.network.rawId - pair.to.contractAddress == toSwapCurrencyStatus.currency.getContractAddress() + pair.to.network == toSwapCurrencyStatus.currency.network.rawId && + pair.to.contractAddress == toSwapCurrencyStatus.currency.getContractAddress() }?.providers.orEmpty() } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 12d6bb07b8..04428126e9 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -3,16 +3,15 @@ package com.tangem.feature.swap.analytics import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO -import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN -import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM import com.tangem.core.analytics.models.AppsFlyerIncludedEvent import com.tangem.core.analytics.models.getReferralParams +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.FeeType @@ -25,13 +24,15 @@ sealed class SwapEvents( ) : AnalyticsEvent(SWAP_CATEGORY, event, params) { class SwapScreenOpened( - val token: String, - val blockchain: String, + val fromCurrency: CryptoCurrency?, + val toCurrency: CryptoCurrency?, ) : SwapEvents( event = "Swap Screen Opened", params = mapOf( - TOKEN_PARAM to token, - BLOCKCHAIN to blockchain, + SEND_TOKEN to fromCurrency?.symbol.orEmpty(), + "Send Blockchain" to fromCurrency?.network?.name.orEmpty(), + RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(), + "Receive Blockchain" to toCurrency?.network?.name.orEmpty(), ), ), AppsFlyerIncludedEvent @@ -48,6 +49,31 @@ sealed class SwapEvents( }, ) + class ChoosePopularToken( + val direction: String, + val currency: CryptoCurrency, + ) : SwapEvents( + event = "Choose popular token", + params = mapOf( + "Direction" to direction, + "Token" to currency.symbol, + "Blockchain" to currency.network.name, + ), + ) + + class PreselectedTokenChanged( + val direction: String, + val preSelectedToken: CryptoCurrency, + val selectedToken: CryptoCurrency, + ) : SwapEvents( + event = "Pre-selected token changed", + params = mapOf( + "Direction" to direction, + "Pre-selected Token" to preSelectedToken.symbol, + "Selected Token" to selectedToken.symbol, + ), + ) + class ButtonSwapClicked(val sendToken: String, val receiveToken: String) : SwapEvents( event = "Button - Swap", params = mapOf("Send Token" to sendToken, "Receive Token" to receiveToken), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 7778741e7a..6755e4209a 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -227,6 +227,9 @@ internal class SwapModel @Inject constructor( private var isAmountChangedByUser: Boolean = false private var lastPermissionNotificationTokens: Pair? = null + private var preselectedFromCurrency: CryptoCurrency? = null + private var preselectedToCurrency: CryptoCurrency? = null + val approvalSlotNavigation = SlotNavigation() val approvalCallback = object : GiveApprovalComponent.Callback { @@ -275,14 +278,6 @@ internal class SwapModel @Inject constructor( initTokens() - // TODO swap analytics - analyticsEventHandler.send( - SwapEvents.SwapScreenOpened( - token = initialCryptoCurrency?.symbol.orEmpty(), - blockchain = initialCryptoCurrency?.network?.name.orEmpty(), - ), - ) - getBalanceHidingSettingsUseCase().onEach { settings -> isBalanceHidden = settings.isBalanceHidden uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden) @@ -304,9 +299,28 @@ internal class SwapModel @Inject constructor( } private fun subscribeToTokenSelection() { + fun sendAnalytics(result: ChooseTokenResult, direction: String) { + result.analyticsPayload.forEach { analyticPayload -> + when (analyticPayload) { + is ChooseTokenAnalyticsPayload.IsMarketTokenSelected -> { + if (analyticPayload.value) { + analyticsEventHandler.send( + SwapEvents.ChoosePopularToken( + direction = direction, + currency = result.currency.currency, + ), + ) + } + } + else -> Unit + } + } + } + chooseFromTokenBridge.onCurrencyChosen.receiveAsFlow() .onEach { result -> - onTokenSelect(result, isFromDirection = true) + onTokenSelect(result = result, isFromDirection = true) + sendAnalytics(result = result, direction = "From") } .launchIn(modelScope) @@ -320,6 +334,7 @@ internal class SwapModel @Inject constructor( chooseToTokenBridge.onCurrencyChosen.receiveAsFlow() .onEach { result -> onTokenSelect(result, isFromDirection = false) + sendAnalytics(result = result, direction = "To") } .launchIn(modelScope) @@ -342,6 +357,16 @@ internal class SwapModel @Inject constructor( isPaymentAccount = params.tangemPayInput != null, ) + preselectedFromCurrency = fromSwapCurrencyStatus?.currency + preselectedToCurrency = toSwapCurrencyStatus?.currency + + analyticsEventHandler.send( + SwapEvents.SwapScreenOpened( + fromCurrency = fromSwapCurrencyStatus?.currency, + toCurrency = toSwapCurrencyStatus?.currency, + ), + ) + dataState = dataState.copy( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, @@ -416,6 +441,11 @@ internal class SwapModel @Inject constructor( return } + sendPreselectedTokenChangedIfNeeded( + isFromDirection = isFromDirection, + selectedCurrency = selectedCurrencyStatus.currency, + ) + dataState = if (isFromDirection) { // Reset amount if from token is changed lastAmount.value = INITIAL_AMOUNT @@ -465,6 +495,25 @@ internal class SwapModel @Inject constructor( } } + private fun sendPreselectedTokenChangedIfNeeded(isFromDirection: Boolean, selectedCurrency: CryptoCurrency) { + val preselected = if (isFromDirection) preselectedFromCurrency else preselectedToCurrency + if (preselected == null || preselected == selectedCurrency) return + + analyticsEventHandler.send( + SwapEvents.PreselectedTokenChanged( + direction = if (isFromDirection) "From" else "To", + preSelectedToken = preselected, + selectedToken = selectedCurrency, + ), + ) + + if (isFromDirection) { + preselectedFromCurrency = null + } else { + preselectedToCurrency = null + } + } + private fun onChangeCardsClicked() { modelScope.launch { singleTaskScheduler.cancelTask() From 7cda9d6726e1f4b23b14f1de5c1d4a8ba3871138 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 02:57:05 -0700 Subject: [PATCH 159/206] Updated on 2026-08-14 --- .../setup/TangemPayCardLimitSetupModel.kt | 8 ++- .../setup/TangemPayCardLimitSetupModelTest.kt | 63 +++++++++++++------ 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index 7672c8911c..d832e12eea 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -44,6 +44,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( ) : Model() { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() + private var currentAdminLimit: BigDecimal? = null val uiState: StateFlow field = MutableStateFlow( @@ -83,7 +84,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( ?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } ?.amount - val adminLimit = card.limit?.adminCardLimit + currentAdminLimit = card.limit?.adminCardLimit ?.takeIf { it.period == TangemPayCardLimitPeriod.DAY } ?.amount @@ -101,7 +102,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( decimals = currency.defaultFractionDigits, onValueChange = ::onAmountChange, ), - subtitle = buildSubtitle(adminLimit, currency), + subtitle = buildSubtitle(currentAdminLimit, currency), currencyCode = currency.symbol, presets = buildPresets(currency), isSubmitButtonEnabled = isValid(amount), @@ -153,7 +154,8 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( private fun isValid(value: String): Boolean { val amount = value.toBigDecimalOrNull() ?: return false - return amount >= MIN_LIMIT + val isLessThanMax = currentAdminLimit?.let { amount <= it } != false + return amount >= MIN_LIMIT && isLessThanMax } private fun buildSubtitle(maxLimit: BigDecimal?, currency: Currency): TextReference { diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index 3213e42482..b99ecc5bb7 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -8,6 +8,9 @@ import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardLimit +import com.tangem.domain.models.pay.TangemPayCardLimitData +import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier @@ -24,6 +27,7 @@ import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class TangemPayCardLimitSetupModelTest { @@ -50,27 +54,35 @@ internal class TangemPayCardLimitSetupModelTest { ), ) - private val testCard = TangemPayCard( - id = cardId, - hasPinCode = false, - displayName = null, - limit = null, - isFrozen = false, - lastDigits = "1234", - ) + private fun createModel( + adminLimit: BigDecimal? = BigDecimal("1000"), + ): TangemPayCardLimitSetupModel { + val cardWithLimit = TangemPayCard( + id = cardId, + hasPinCode = false, + displayName = null, + isFrozen = false, + lastDigits = "1234", + limit = TangemPayCardLimitData( + actualCardLimit = null, + adminCardLimit = adminLimit?.let { + TangemPayCardLimit( + amount = adminLimit, + period = TangemPayCardLimitPeriod.DAY, + ) + } + ), + ) + val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { + every { source } returns StatusSource.ACTUAL + every { cards } returns listOf(cardWithLimit) + every { currencyCode } returns "USD" + } + val paymentStatusWithLimit: AccountStatus.Payment = mockk(relaxed = true) { + every { value } returns statusWithLimit + } + every { paymentAccountStatusSupplier.invoke(userWalletId) } returns flowOf(paymentStatusWithLimit) - private val loadedStatus: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { - every { source } returns StatusSource.ACTUAL - every { cards } returns listOf(testCard) - every { currencyCode } returns "USD" - } - - private val paymentStatus: AccountStatus.Payment = mockk(relaxed = true) { - every { value } returns loadedStatus - } - - private fun createModel(): TangemPayCardLimitSetupModel { - every { paymentAccountStatusSupplier.invoke(userWalletId) } returns flowOf(paymentStatus) return TangemPayCardLimitSetupModel( paramsContainer = MutableParamsContainer(params), dispatchers = TestingCoroutineDispatcherProvider(), @@ -95,6 +107,16 @@ internal class TangemPayCardLimitSetupModelTest { model.onDestroy() } + @Test + fun `GIVEN max limit null WHEN changed THEN submit button is enabled`() { + val model = createModel(adminLimit = null) + + model.uiState.value.amountFieldModel.onValueChange("100") + + assertThat(model.uiState.value.isSubmitButtonEnabled).isTrue() + model.onDestroy() + } + @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class Presets { @@ -128,5 +150,6 @@ internal class TangemPayCardLimitSetupModelTest { Arguments.of("-1", false), Arguments.of("", false), Arguments.of("abc", false), + Arguments.of("1001", false), ) } \ No newline at end of file From e5825252f21b3e277635b0be7efe21b03163b3bc Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 13:59:41 +0400 Subject: [PATCH 160/206] Updated on 2026-08-14 --- .../DefaultCustomTokensRepository.kt | 59 +++++++++++-------- .../managetokens/di/ManageTokensDataModule.kt | 4 ++ .../utils/HederaTokenAddressResolver.kt | 41 +++++++++++++ .../utils/TokenAddressesConverter.kt | 12 ++-- gradle/tangem_dependencies.toml | 2 +- 5 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/HederaTokenAddressResolver.kt diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt index db25b8597e..9d48157eef 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/DefaultCustomTokensRepository.kt @@ -7,6 +7,7 @@ import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.managetokens.utils.HederaTokenAddressResolver import com.tangem.data.managetokens.utils.TokenAddressesConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi @@ -32,6 +33,7 @@ internal class DefaultCustomTokensRepository( private val excludedBlockchains: ExcludedBlockchains, private val dispatchers: CoroutineDispatcherProvider, private val networkFactory: NetworkFactory, + hederaTokenAddressResolver: HederaTokenAddressResolver, ) : CustomTokensRepository { private val excludedBlockchainsForCustom = setOf( @@ -40,7 +42,7 @@ internal class DefaultCustomTokensRepository( ) private val cryptoCurrencyFactory = CryptoCurrencyFactory(excludedBlockchains) - private val tokenAddressConverter = TokenAddressesConverter() + private val tokenAddressConverter = TokenAddressesConverter(hederaTokenAddressResolver) override suspend fun validateContractAddress(contractAddress: String, networkId: Network.ID): Boolean = withContext(dispatchers.io) { @@ -53,6 +55,8 @@ internal class DefaultCustomTokensRepository( Blockchain.TerraV2, -> true Blockchain.Cardano, + Blockchain.Hedera, + Blockchain.HederaTestnet, Blockchain.Sui, Blockchain.Stellar, Blockchain.XRP, @@ -89,30 +93,39 @@ internal class DefaultCustomTokensRepository( .filter(Blockchain::canHandleTokens) .map(Blockchain::toNetworkId) - val response = tangemTechApi.getCoins( - contractAddress = contractAddress, - networkIds = network.rawId, - active = true, - ).getOrThrow() - - response.coins.firstNotNullOfOrNull { coin -> - val coinNetwork = coin.networks.firstOrNull { network -> - (network.contractAddress != null || network.decimalCount != null) && - network.contractAddress.equals(tokenAddress, ignoreCase = true) && - network.networkId in supportedTokenNetworkIds + val addressesToTry = buildList { + add(tokenAddress) + if (!contractAddress.equals(tokenAddress, ignoreCase = true)) { + add(contractAddress) } + } - if (coinNetwork != null) { - cryptoCurrencyFactory.createToken( - network = network, - rawId = CryptoCurrency.RawID(coin.id), - name = coin.name, - symbol = coin.symbol, - decimals = requireNotNull(coinNetwork.decimalCount).toInt(), - contractAddress = tokenAddress, - ) - } else { - null + addressesToTry.firstNotNullOfOrNull { searchAddress -> + val response = tangemTechApi.getCoins( + contractAddress = searchAddress, + networkIds = network.rawId, + active = true, + ).getOrThrow() + + response.coins.firstNotNullOfOrNull { coin -> + val coinNetwork = coin.networks.firstOrNull { network -> + (network.contractAddress != null || network.decimalCount != null) && + network.contractAddress.equals(tokenAddress, ignoreCase = true) && + network.networkId in supportedTokenNetworkIds + } + + if (coinNetwork != null) { + cryptoCurrencyFactory.createToken( + network = network, + rawId = CryptoCurrency.RawID(coin.id), + name = coin.name, + symbol = coin.symbol, + decimals = requireNotNull(coinNetwork.decimalCount).toInt(), + contractAddress = tokenAddress, + ) + } else { + null + } } } } diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt index 2df7ecce2c..7c1a1c7dd8 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/di/ManageTokensDataModule.kt @@ -1,10 +1,12 @@ package com.tangem.data.managetokens.di +import com.tangem.blockchainsdk.providers.BlockchainProviderTypesStore import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.data.common.account.WalletAccountsFetcher import com.tangem.data.common.network.NetworkFactory import com.tangem.data.managetokens.DefaultCustomTokensRepository import com.tangem.data.managetokens.DefaultManageTokensRepository +import com.tangem.data.managetokens.utils.HederaTokenAddressResolver import com.tangem.data.managetokens.utils.ManageTokensUpdateFetcher import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.config.testnet.TestnetTokensStorage @@ -54,6 +56,7 @@ internal object ManageTokensDataModule { dispatchers: CoroutineDispatcherProvider, excludedBlockchains: ExcludedBlockchains, networkFactory: NetworkFactory, + blockchainProviderTypesStore: BlockchainProviderTypesStore, ): CustomTokensRepository { return DefaultCustomTokensRepository( tangemTechApi = tangemTechApi, @@ -61,6 +64,7 @@ internal object ManageTokensDataModule { excludedBlockchains = excludedBlockchains, dispatchers = dispatchers, networkFactory = networkFactory, + hederaTokenAddressResolver = HederaTokenAddressResolver(blockchainProviderTypesStore), ) } } \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/HederaTokenAddressResolver.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/HederaTokenAddressResolver.kt new file mode 100644 index 0000000000..f48ecbe0f0 --- /dev/null +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/HederaTokenAddressResolver.kt @@ -0,0 +1,41 @@ +package com.tangem.data.managetokens.utils + +import com.tangem.blockchain.blockchains.hedera.HederaContractIdResolver +import com.tangem.blockchain.blockchains.hedera.HederaTokenAddressConverter +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.network.providers.ProviderType +import com.tangem.blockchainsdk.providers.BlockchainProviderTypesStore +import java.util.concurrent.ConcurrentHashMap + +internal class HederaTokenAddressResolver( + private val blockchainProviderTypesStore: BlockchainProviderTypesStore, +) { + private val tokenAddressConverter = HederaTokenAddressConverter() + private val cache = ConcurrentHashMap() + + suspend fun resolveAddress(blockchain: Blockchain, contractAddress: String): String { + cache[contractAddress.lowercase()]?.let { return it } + + val resolver = HederaContractIdResolver(baseUrl = getBaseUrl(blockchain)) + val resolved = tokenAddressConverter.resolveTokenId(contractAddress) { resolver.resolve(it) } + + require(!resolved.startsWith("0x", ignoreCase = true)) { + "Failed to resolve Hedera contract ID for EVM address: $contractAddress" + } + + cache[contractAddress.lowercase()] = resolved + return resolved + } + + private fun getBaseUrl(blockchain: Blockchain): String { + val providerTypes = blockchainProviderTypesStore.get().value + return requireNotNull( + providerTypes[blockchain] + ?.filterIsInstance() + ?.firstOrNull() + ?.url, + ) { + "Hedera provider URL not found for $blockchain" + } + } +} \ No newline at end of file diff --git a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/TokenAddressesConverter.kt b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/TokenAddressesConverter.kt index bb7e90cba1..e663936303 100644 --- a/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/TokenAddressesConverter.kt +++ b/data/manage-tokens/src/main/kotlin/com/tangem/data/managetokens/utils/TokenAddressesConverter.kt @@ -1,7 +1,6 @@ package com.tangem.data.managetokens.utils import com.tangem.blockchain.blockchains.cardano.CardanoTokenAddressConverter -import com.tangem.blockchain.blockchains.hedera.HederaTokenAddressConverter import com.tangem.blockchain.blockchains.stellar.StellarTokenAddressConverter import com.tangem.blockchain.blockchains.sui.SuiTokenAddressConverter import com.tangem.blockchain.blockchains.xrp.XrpTokenAddressConverter @@ -9,18 +8,19 @@ import com.tangem.blockchain.common.Blockchain import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.models.network.Network -internal class TokenAddressesConverter { - private val hederaTokenAddressConverter = HederaTokenAddressConverter() +internal class TokenAddressesConverter( + private val hederaTokenAddressResolver: HederaTokenAddressResolver, +) { private val cardanoTokenAddressConverter = CardanoTokenAddressConverter() private val xrpTokenAddressConverter = XrpTokenAddressConverter() private val stellarTokenAddressConverter = StellarTokenAddressConverter() private val suiTokenAddressConverter = SuiTokenAddressConverter() - fun convertTokenAddress(networkId: Network.ID, contractAddress: String, symbol: String?): String { - val convertedAddress = when (networkId.toBlockchain()) { + suspend fun convertTokenAddress(networkId: Network.ID, contractAddress: String, symbol: String?): String { + val convertedAddress = when (val blockchain = networkId.toBlockchain()) { Blockchain.Hedera, Blockchain.HederaTestnet, - -> hederaTokenAddressConverter.convertToTokenId(contractAddress) + -> hederaTokenAddressResolver.resolveAddress(blockchain, contractAddress) Blockchain.Sui, Blockchain.SuiTestnet, -> suiTokenAddressConverter.normalizeAddress(contractAddress) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index f191596235..2e558b3c64 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1498" +tangemBlockchainSdk = "develop-1502" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "develop-614" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 7f2f051415cbc5982c5e182d4d81f70b6b4455a0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 13:16:57 +0300 Subject: [PATCH 161/206] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 2 +- gradle/tangem_dependencies.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index c161b3d49d..71f159b32c 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -41,7 +41,7 @@ }, { "name": "ASSETS_DISCOVERY_ENABLED", - "version": "undefined" + "version": "5.38" }, { "name": "SOLANA_TX_HISTORY_ENABLED", diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 2e558b3c64..7179ef2644 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "develop-1502" +tangemBlockchainSdk = "releases-5.38-1503" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "develop-614" +tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 3d5a8654a5f7e40157be87715c9c7c349c8ed3b1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 5 May 2026 10:31:41 +0500 Subject: [PATCH 162/206] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 20 ++ .../tangem/tap/routing/utils/ChildFactory.kt | 10 + .../tap/routing/utils/DeepLinkFactory.kt | 6 + .../tap/routing/utils/DeepLinkFactoryTest.kt | 28 +- .../com/tangem/common/routing/AppRoute.kt | 7 + .../tangem/common/routing/DeepLinkRoute.kt | 8 + .../common/routing/deeplink/DeeplinkConst.kt | 1 + .../routing/deeplink/DeepLinkBuilderTest.kt | 25 ++ .../domain/models/earn/PreselectedEarnType.kt | 14 + features/feed/api/build.gradle.kts | 1 + .../feed/entry/components/FeedEntryRoute.kt | 7 + .../entry/deeplink/EarnDeepLinkHandler.kt | 8 + .../entry/deeplink/YieldDeepLinkHandler.kt | 10 + features/feed/impl/build.gradle.kts | 1 + .../components/DefaultFeedEntryComponent.kt | 54 +++- .../feed/components/FeedEntryChildFactory.kt | 23 +- .../components/earn/DefaultEarnComponent.kt | 5 + .../deeplink/DefaultEarnDeepLinkHandler.kt | 38 +++ .../deeplink/DefaultYieldDeepLinkHandler.kt | 107 ++++++++ .../feed/deeplink/di/FeedDeepLinkModule.kt | 12 + .../features/feed/model/earn/EarnModel.kt | 57 +++- .../DefaultEarnDeepLinkHandlerTest.kt | 129 +++++++++ .../DefaultYieldDeepLinkHandlerTest.kt | 247 ++++++++++++++++++ 23 files changed, 783 insertions(+), 35 deletions(-) create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/earn/PreselectedEarnType.kt create mode 100644 features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/EarnDeepLinkHandler.kt create mode 100644 features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/YieldDeepLinkHandler.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandler.kt create mode 100644 features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandler.kt create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandlerTest.kt create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandlerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 27f8c3c8e9..f6139be58a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -306,6 +306,26 @@ android:host="news" android:scheme="tangem" /> + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 7351bc519d..7f32d68de6 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -709,6 +709,16 @@ internal class ChildFactory @Inject constructor( componentFactory = feedEntryComponentFactory, ) } + is AppRoute.Earn -> { + createComponentChild( + context = context, + params = FeedEntryRoute.Earn( + preselectedEarnType = route.preselectedEarnType, + preselectedNetworkId = route.preselectedNetworkId, + ), + componentFactory = feedEntryComponentFactory, + ) + } } } } diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 3e9d268dd5..23e1f1f8d7 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -6,11 +6,13 @@ import com.tangem.common.routing.DeepLinkRoute import com.tangem.common.routing.DeepLinkScheme import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler @@ -56,6 +58,8 @@ internal class DeepLinkFactory @Inject constructor( private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory, private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, private val newsDeepLink: NewsDeepLinkHandler.Factory, + private val earnDeepLink: EarnDeepLinkHandler.Factory, + private val yieldDeepLink: YieldDeepLinkHandler.Factory, ) { private val permittedAppRoute = MutableStateFlow(false) @@ -162,6 +166,8 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri) DeepLinkRoute.News.host -> newsDeepLink.create(queryParams) + DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams) + DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) else -> { TangemLogger.i( """ diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index 7a1c5173b5..0c50d75372 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -4,11 +4,13 @@ import android.net.Uri import com.tangem.common.routing.AppRoute import com.tangem.data.card.sdk.CardSdkProvider import com.tangem.feature.referral.api.deeplink.ReferralDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenDetailDeepLinkHandler import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler import com.tangem.features.onramp.deeplink.SellDeepLinkHandler @@ -92,6 +94,14 @@ class DeepLinkFactoryTest { every { create(any()) } returns mockk() } + private val earnDeepLinkFactory = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + + private val yieldDeepLinkFactory = mockk(relaxed = true) { + every { create(any(), any()) } returns mockk() + } + private val marketsTokenExchangesDeepLinkFactory = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() @@ -122,6 +132,8 @@ class DeepLinkFactoryTest { onboardVisaDeepLink = onboardVisaDeepLink, newsDetailsDeepLink = newsDeeplink, newsDeepLink = newsDeepLinkFactory, + earnDeepLink = earnDeepLinkFactory, + yieldDeepLink = yieldDeepLinkFactory, ) @OptIn(ExperimentalCoroutinesApi::class) @@ -439,18 +451,28 @@ class DeepLinkFactoryTest { } @Test - fun `handleTangemDeepLinks routes news host to dedicated handler`() = runTest { + fun `handleTangemDeepLinks routes news, earn and yield hosts to dedicated handlers`() = runTest { every { mockedUri.scheme } returns "tangem" - every { mockedUri.host } returns "news" every { mockedUri.query } returns null every { mockedUri.queryParameterNames } returns emptySet() every { mockedUri.getQueryParameter(any()) } returns null deepLinkFactory.checkRoutingReadiness(AppRoute.Wallet) + + every { mockedUri.host } returns "news" deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) advanceUntilIdle() - verify { newsDeepLinkFactory.create(eq(emptyMap())) } + + every { mockedUri.host } returns "earn" + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + verify { earnDeepLinkFactory.create(eq(emptyMap())) } + + every { mockedUri.host } returns "yield" + deepLinkFactory.handleDeeplink(mockedUri, testScope, isFromOnNewIntent) + advanceUntilIdle() + verify { yieldDeepLinkFactory.create(eq(testScope), eq(emptyMap())) } } @Test diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 3a6eac7c41..42bedf6b3e 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -17,6 +17,7 @@ import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountId import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.earn.PreselectedEarnType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.serialization.SerializedBigDecimal import com.tangem.domain.models.wallet.UserWalletId @@ -494,4 +495,10 @@ sealed class AppRoute(val path: String) : Route { data class News( val categoryId: Int? = null, ) : AppRoute(path = "/news") + + @Serializable + data class Earn( + val preselectedEarnType: PreselectedEarnType? = null, + val preselectedNetworkId: String? = null, + ) : AppRoute(path = "/earn") } \ No newline at end of file diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index b9caabe5d0..0bdcdc9f27 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -75,6 +75,14 @@ sealed class DeepLinkRoute { data object News : DeepLinkRoute() { override val host: String = "news" } + + data object Earn : DeepLinkRoute() { + override val host: String = "earn" + } + + data object Yield : DeepLinkRoute() { + override val host: String = "yield" + } } enum class DeepLinkScheme(val scheme: String) { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt index 8f745a3b32..5196edd70a 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/DeeplinkConst.kt @@ -20,4 +20,5 @@ object DeeplinkConst { const val SECTION_KEY = "section" const val CATEGORY_ID_KEY = "category_id" const val NEWS_ID_KEY = "news_id" + const val EARN_TYPE_KEY = "earn_type" } \ No newline at end of file diff --git a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt index bdcb99e48d..4e7aab6d8b 100644 --- a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt +++ b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/DeepLinkBuilderTest.kt @@ -108,6 +108,31 @@ internal class DeepLinkBuilderTest { assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://news?${DeeplinkConst.NEWS_ID_KEY}=20533") } + @Test + fun `earn host with filters produces expected uri`() { + val result = deepLinkBuilder + .setAction("earn") + .addQueryParam(DeeplinkConst.EARN_TYPE_KEY, "yield") + .addQueryParam(DeeplinkConst.NETWORK_ID_KEY, "base") + .build() + + assertThat(result).isEqualTo( + "${DeeplinkConst.TANGEM_SCHEME}://earn?${DeeplinkConst.EARN_TYPE_KEY}=yield" + + "&${DeeplinkConst.NETWORK_ID_KEY}=base", + ) + } + + @Test + fun `yield host with token and network produces expected uri`() { + val result = deepLinkBuilder + .setAction("yield") + .addQueryParam(DeeplinkConst.TOKEN_ID_KEY, "usd-coin") + .addQueryParam(DeeplinkConst.NETWORK_ID_KEY, "base") + .build() + + assertThat(result).isEqualTo("${DeeplinkConst.TANGEM_SCHEME}://yield?token_id=usd-coin&network_id=base") + } + @Test fun `GIVEN complex deep link WHEN build THEN should construct correct URI`() { // GIVEN diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/earn/PreselectedEarnType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/PreselectedEarnType.kt new file mode 100644 index 0000000000..51d961c48a --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/earn/PreselectedEarnType.kt @@ -0,0 +1,14 @@ +package com.tangem.domain.models.earn + +import kotlinx.serialization.Serializable + +@Serializable +enum class PreselectedEarnType(val value: String) { + Staking("staking"), + Yield("yield"), + ; + + companion object { + fun parse(value: String?): PreselectedEarnType? = entries.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/features/feed/api/build.gradle.kts b/features/feed/api/build.gradle.kts index 890d5aefad..b0a3a10ffc 100644 --- a/features/feed/api/build.gradle.kts +++ b/features/feed/api/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { /* Project - Domain */ implementation(projects.domain.core) + implementation(projects.domain.models) implementation(projects.domain.tokens.models) implementation(projects.domain.appCurrency.models) implementation(projects.domain.markets.models) diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt index 098b29f4da..38225e22f9 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryRoute.kt @@ -5,6 +5,7 @@ import com.tangem.domain.markets.PreselectedMarketsInterval import com.tangem.domain.markets.PreselectedMarketsOrder import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.earn.PreselectedEarnType import kotlinx.serialization.Serializable @Serializable @@ -39,4 +40,10 @@ sealed interface FeedEntryRoute { @Serializable data class NewsList(val preselectedCategoryId: Int? = null) : FeedEntryRoute + + @Serializable + data class Earn( + val preselectedEarnType: PreselectedEarnType? = null, + val preselectedNetworkId: String? = null, + ) : FeedEntryRoute } \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/EarnDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/EarnDeepLinkHandler.kt new file mode 100644 index 0000000000..01c17aed53 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/EarnDeepLinkHandler.kt @@ -0,0 +1,8 @@ +package com.tangem.features.feed.entry.deeplink + +interface EarnDeepLinkHandler { + + interface Factory { + fun create(queryParams: Map): EarnDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/YieldDeepLinkHandler.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/YieldDeepLinkHandler.kt new file mode 100644 index 0000000000..5512452cb0 --- /dev/null +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/deeplink/YieldDeepLinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.feed.entry.deeplink + +import kotlinx.coroutines.CoroutineScope + +interface YieldDeepLinkHandler { + + interface Factory { + fun create(coroutineScope: CoroutineScope, queryParams: Map): YieldDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index 7d4dc84f32..a9908d1787 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -112,4 +112,5 @@ dependencies { testRuntimeOnly(deps.test.junit5.engine) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) + testImplementation(deps.test.coroutine) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 198cdac711..94c272503e 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -21,10 +21,13 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketParams +import com.tangem.domain.models.earn.PreselectedEarnType import com.tangem.domain.news.model.NewsListConfig +import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.market.details.DefaultMarketsTokenDetailsComponent import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent +import com.tangem.features.feed.components.news.list.DefaultNewsListComponent import com.tangem.features.feed.entry.components.FeedEntryComponent import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.feed.model.FeedEntryModel @@ -125,11 +128,19 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( } override fun onOpenAllNews() { - innerRouter.push(FeedEntryChildFactory.Child.NewsList()) + innerRouter.push( + FeedEntryChildFactory.Child.NewsList( + params = buildNewsListParams(onBack = { onChildBack() }), + ), + ) } override fun onOpenEarnPage() { - innerRouter.push(FeedEntryChildFactory.Child.Earn) + innerRouter.push( + FeedEntryChildFactory.Child.Earn( + params = buildEarnParams(onBack = { onChildBack() }), + ), + ) } override fun openSearch(source: String) { @@ -264,12 +275,49 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( ), ) is FeedEntryRoute.NewsList -> FeedEntryChildFactory.Child.NewsList( - preselectedCategoryId = entryRoute.preselectedCategoryId, + params = buildNewsListParams( + onBack = { router.pop() }, + preselectedCategoryId = entryRoute.preselectedCategoryId, + ), + ) + is FeedEntryRoute.Earn -> FeedEntryChildFactory.Child.Earn( + params = buildEarnParams( + onBack = { router.pop() }, + preselectedEarnType = entryRoute.preselectedEarnType, + preselectedNetworkId = entryRoute.preselectedNetworkId, + ), ) null -> FeedEntryChildFactory.Child.Feed } } + private fun buildNewsListParams( + onBack: () -> Unit, + preselectedCategoryId: Int? = null, + ): DefaultNewsListComponent.Params = DefaultNewsListComponent.Params( + onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig -> + clickIntents.onArticleClick( + articleId = currentArticle, + preselectedArticlesId = prefetchedArticles, + screenSource = AnalyticsParam.ScreensSources.NewsList, + paginationConfig = paginationConfig, + ) + }, + onBackClick = onBack, + preselectedCategoryId = preselectedCategoryId, + ) + + private fun buildEarnParams( + onBack: () -> Unit, + preselectedEarnType: PreselectedEarnType? = null, + preselectedNetworkId: String? = null, + ): DefaultEarnComponent.Params = DefaultEarnComponent.Params( + onBackClick = onBack, + onSearchClicked = clickIntents::openSearch, + preselectedEarnType = preselectedEarnType, + preselectedNetworkId = preselectedNetworkId, + ) + @AssistedFactory interface Factory : FeedEntryComponent.Factory { override fun create(context: AppComponentContext, entryRoute: FeedEntryRoute?): DefaultFeedEntryComponent diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index fb8c3a7f90..4ae731f39c 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -17,7 +17,6 @@ import com.tangem.features.feed.components.market.details.portfolioblock.Portfol import com.tangem.features.feed.components.market.list.DefaultMarketsTokenListComponent import com.tangem.features.feed.components.news.details.DefaultNewsDetailsComponent import com.tangem.features.feed.components.news.list.DefaultNewsListComponent -import com.tangem.features.feed.components.news.list.DefaultNewsListComponent.Params import com.tangem.features.feed.components.search.DefaultSearchComponent import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles import com.tangem.features.promobanners.api.PromoBannersBlockComponent @@ -53,7 +52,7 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable - data class NewsList(val preselectedCategoryId: Int? = null) : Child + data class NewsList(val params: DefaultNewsListComponent.Params) : Child @Serializable @Immutable @@ -61,7 +60,7 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable - data object Earn : Child + data class Earn(val params: DefaultEarnComponent.Params) : Child @Serializable @Immutable @@ -113,18 +112,7 @@ internal class FeedEntryChildFactory @Inject constructor( is Child.NewsList -> { DefaultNewsListComponent( appComponentContext = appComponentContext, - params = Params( - onArticleClicked = { currentArticle, prefetchedArticles, paginationConfig -> - feedEntryClickIntents.onArticleClick( - articleId = currentArticle, - preselectedArticlesId = prefetchedArticles, - screenSource = AnalyticsParam.ScreensSources.NewsList, - paginationConfig = paginationConfig, - ) - }, - onBackClick = onBackClicked, - preselectedCategoryId = child.preselectedCategoryId, - ), + params = child.params, ) } Child.Feed -> { @@ -139,10 +127,7 @@ internal class FeedEntryChildFactory @Inject constructor( is Child.Earn -> { DefaultEarnComponent( appComponentContext = appComponentContext, - params = DefaultEarnComponent.Params( - onBackClick = onBackClicked, - onSearchClicked = feedEntryClickIntents::openSearch, - ), + params = child.params, addToPortfolioComponentFactory = addToPortfolioComponentFactory, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index bdef4f7ffe..9e4094404f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -34,7 +34,9 @@ import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent +import com.tangem.domain.models.earn.PreselectedEarnType import com.tangem.features.feed.components.feed.FeedBottomSheetRoute +import kotlinx.serialization.Serializable import com.tangem.features.feed.model.earn.EarnModel import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.ui.components.FeedSearchBar @@ -151,8 +153,11 @@ internal class DefaultEarnComponent( ) } + @Serializable data class Params( val onBackClick: () -> Unit, val onSearchClicked: (source: String) -> Unit, + val preselectedEarnType: PreselectedEarnType? = null, + val preselectedNetworkId: String? = null, ) } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandler.kt new file mode 100644 index 0000000000..4609b29cfb --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandler.kt @@ -0,0 +1,38 @@ +package com.tangem.features.feed.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.EARN_TYPE_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY +import com.tangem.domain.models.earn.PreselectedEarnType +import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultEarnDeepLinkHandler @AssistedInject constructor( + @Assisted private val queryParams: Map, + private val appRouter: AppRouter, +) : EarnDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + val earnType = PreselectedEarnType.parse(queryParams[EARN_TYPE_KEY]) + val networkId = queryParams[NETWORK_ID_KEY]?.takeIf { it.isNotBlank() } + + appRouter.push( + AppRoute.Earn( + preselectedEarnType = earnType, + preselectedNetworkId = networkId, + ), + ) + } + + @AssistedFactory + interface Factory : EarnDeepLinkHandler.Factory { + override fun create(queryParams: Map): DefaultEarnDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandler.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandler.kt new file mode 100644 index 0000000000..ee24674898 --- /dev/null +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandler.kt @@ -0,0 +1,107 @@ +package com.tangem.features.feed.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY +import com.tangem.domain.models.earn.PreselectedEarnType +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.yield.supply.models.YieldSupplyAvailability +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase +import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +internal class DefaultYieldDeepLinkHandler @AssistedInject constructor( + @Assisted private val scope: CoroutineScope, + @Assisted private val queryParams: Map, + private val appRouter: AppRouter, + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase, + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier, + private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase, +) : YieldDeepLinkHandler { + + init { + handleDeepLink() + } + + private fun handleDeepLink() { + val tokenId = queryParams[TOKEN_ID_KEY]?.takeIf { it.isNotBlank() } + val networkId = queryParams[NETWORK_ID_KEY]?.takeIf { it.isNotBlank() } + + if (tokenId == null || networkId == null) { + TangemLogger.i("Yield deeplink: missing token_id or network_id; falling back to earn yield list") + pushFallback(networkId) + return + } + + scope.launch { + val userWallet = getSelectedWalletSyncUseCase().getOrNull() + if (userWallet == null) { + TangemLogger.e("Yield deeplink: no selected user wallet") + pushFallback(networkId) + return@launch + } + + val cryptoCurrency = multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + params = MultiWalletCryptoCurrenciesProducer.Params(userWallet.walletId), + ) + .orEmpty() + .firstOrNull { currency -> + currency.network.rawId.equals(networkId, ignoreCase = true) && + currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true + } + + if (cryptoCurrency == null) { + TangemLogger.i( + """ + Yield deeplink: token not in selected wallet + |- $TOKEN_ID_KEY: $tokenId + |- $NETWORK_ID_KEY: $networkId + """.trimIndent(), + ) + pushFallback(networkId) + return@launch + } + + val availability = yieldSupplyGetAvailabilityUseCase(cryptoCurrency).getOrNull() + val available = availability as? YieldSupplyAvailability.Available + if (available == null) { + TangemLogger.i("Yield deeplink: yield not available for ${cryptoCurrency.name}") + pushFallback(networkId) + return@launch + } + + appRouter.push( + AppRoute.YieldSupplyEntry( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrency, + apy = available.apy, + ), + ) + } + } + + private fun pushFallback(networkId: String?) { + appRouter.push( + AppRoute.Earn( + preselectedEarnType = PreselectedEarnType.Yield, + preselectedNetworkId = networkId, + ), + ) + } + + @AssistedFactory + interface Factory : YieldDeepLinkHandler.Factory { + override fun create( + coroutineScope: CoroutineScope, + queryParams: Map, + ): DefaultYieldDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt index 05f42eb535..0af064279f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/deeplink/di/FeedDeepLinkModule.kt @@ -1,9 +1,13 @@ package com.tangem.features.feed.deeplink.di +import com.tangem.features.feed.deeplink.DefaultEarnDeepLinkHandler import com.tangem.features.feed.deeplink.DefaultNewsDeepLinkHandler import com.tangem.features.feed.deeplink.DefaultNewsDetailsDeepLinkHandler +import com.tangem.features.feed.deeplink.DefaultYieldDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.EarnDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler +import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -23,4 +27,12 @@ internal interface FeedDeepLinkModule { @Binds @Singleton fun bindNewsDeepLinkHandlerFactory(impl: DefaultNewsDeepLinkHandler.Factory): NewsDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindEarnDeepLinkHandlerFactory(impl: DefaultEarnDeepLinkHandler.Factory): EarnDeepLinkHandler.Factory + + @Binds + @Singleton + fun bindYieldDeepLinkHandlerFactory(impl: DefaultYieldDeepLinkHandler.Factory): YieldDeepLinkHandler.Factory } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index d795a1289d..ae1280b2cf 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -23,6 +23,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnNetworks import com.tangem.domain.models.earn.EarnTokenWithCurrency import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.domain.models.earn.PreselectedEarnType import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent import com.tangem.features.feed.components.earn.EarnTypeFilterComponent @@ -108,7 +109,8 @@ internal class EarnModel @Inject constructor( init { updateInitialState() fetchEarnNetworks() - subscribeOnStoredFilters() + fetchTopEarnTokens() + subscribeOnActiveFilters() subscribeOnNetworks() subscribeOnBatchFlow() subscribeToMostlyUsed() @@ -147,6 +149,45 @@ internal class EarnModel @Inject constructor( ) } + private fun activeFilters(): Flow { + val deeplink = buildDeeplinkFilter() ?: return getEarnFilterUseCase() + return getEarnFilterUseCase().drop(1).onStart { emit(deeplink) } + } + + private fun buildDeeplinkFilter(): EarnFilter? { + val type = params.preselectedEarnType?.toEarnFilterType() + val networkId = params.preselectedNetworkId?.takeIf { it.isNotBlank() } + if (type == null && networkId == null) return null + return EarnFilter( + earnFilterType = type ?: EarnFilterType.ALL, + earnFilterNetwork = networkId + ?.let { EarnFilterNetwork.Specific(id = it, symbol = "", fullName = it, isSelected = true) } + ?: EarnFilterNetwork.AllNetworks(isSelected = true), + ) + } + + private fun EarnFilter.resolveAgainst(networks: EarnNetworks): EarnFilter { + val specific = earnFilterNetwork as? EarnFilterNetwork.Specific ?: return this + if (specific.symbol.isNotEmpty()) return this + val loaded = networks.getOrNull()?.takeIf { it.isNotEmpty() } ?: return this + val match = loaded.firstOrNull { it.networkId.equals(specific.id, ignoreCase = true) } + return copy( + earnFilterNetwork = match?.let { earnNetwork -> + EarnFilterNetwork.Specific( + isSelected = true, + id = earnNetwork.networkId, + symbol = earnNetwork.symbol, + fullName = earnNetwork.fullName, + ) + } ?: EarnFilterNetwork.AllNetworks(isSelected = true), + ) + } + + private fun PreselectedEarnType.toEarnFilterType(): EarnFilterType = when (this) { + PreselectedEarnType.Staking -> EarnFilterType.STAKING + PreselectedEarnType.Yield -> EarnFilterType.YIELD + } + private fun subscribeOnBatchFlow() { combine( batchFlowManager.uiItems, @@ -193,18 +234,14 @@ internal class EarnModel @Inject constructor( } } - private fun subscribeOnStoredFilters() { + private fun subscribeOnActiveFilters() { modelScope.launch(dispatchers.default) { - combine( - getEarnFilterUseCase(), - earnNetworks, - ) { filter, networks -> - val typeFilterUM = EarnFilterTypeConverter().convert(filter.earnFilterType) - val networkFilterUM = EarnFilterNetworkConverter().convert(filter.earnFilterNetwork) + combine(activeFilters(), earnNetworks) { filter, networks -> + val resolved = filter.resolveAgainst(networks) stateController.update( EarnFilterSelectedStateTransformer( - filterType = typeFilterUM, - filterNetwork = networkFilterUM, + filterType = EarnFilterTypeConverter().convert(resolved.earnFilterType), + filterNetwork = EarnFilterNetworkConverter().convert(resolved.earnFilterNetwork), earnNetworks = networks, ), ) diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandlerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..bdaf14d99c --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultEarnDeepLinkHandlerTest.kt @@ -0,0 +1,129 @@ +package com.tangem.features.feed.deeplink + +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.EARN_TYPE_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY +import com.tangem.domain.models.earn.PreselectedEarnType +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class DefaultEarnDeepLinkHandlerTest { + + private val appRouter: AppRouter = mockk() + + @BeforeEach + fun setUp() { + every { appRouter.push(any(), any()) } just Runs + } + + @Test + fun `no params opens earn list with no filters`() { + DefaultEarnDeepLinkHandler(queryParams = emptyMap(), appRouter = appRouter) + + verify { + appRouter.push( + AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = null), + any(), + ) + } + } + + @Test + fun `staking earnType is parsed`() { + DefaultEarnDeepLinkHandler( + queryParams = mapOf(EARN_TYPE_KEY to "staking"), + appRouter = appRouter, + ) + + verify { + appRouter.push( + AppRoute.Earn(preselectedEarnType = PreselectedEarnType.Staking, preselectedNetworkId = null), + any(), + ) + } + } + + @Test + fun `yield earnType is parsed`() { + DefaultEarnDeepLinkHandler( + queryParams = mapOf(EARN_TYPE_KEY to "yield"), + appRouter = appRouter, + ) + + verify { + appRouter.push( + AppRoute.Earn(preselectedEarnType = PreselectedEarnType.Yield, preselectedNetworkId = null), + any(), + ) + } + } + + @Test + fun `invalid earnType is silently dropped`() { + DefaultEarnDeepLinkHandler( + queryParams = mapOf(EARN_TYPE_KEY to "bogus"), + appRouter = appRouter, + ) + + verify { + appRouter.push( + AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = null), + any(), + ) + } + } + + @Test + fun `networkId is passed through`() { + DefaultEarnDeepLinkHandler( + queryParams = mapOf(NETWORK_ID_KEY to "base"), + appRouter = appRouter, + ) + + verify { + appRouter.push( + AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = "base"), + any(), + ) + } + } + + @Test + fun `blank networkId is dropped`() { + DefaultEarnDeepLinkHandler( + queryParams = mapOf(NETWORK_ID_KEY to " "), + appRouter = appRouter, + ) + + verify { + appRouter.push( + AppRoute.Earn(preselectedEarnType = null, preselectedNetworkId = null), + any(), + ) + } + } + + @Test + fun `earnType and networkId together`() { + DefaultEarnDeepLinkHandler( + queryParams = mapOf(EARN_TYPE_KEY to "yield", NETWORK_ID_KEY to "base"), + appRouter = appRouter, + ) + + verify { + appRouter.push( + AppRoute.Earn( + preselectedEarnType = PreselectedEarnType.Yield, + preselectedNetworkId = "base", + ), + any(), + ) + } + } +} \ No newline at end of file diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandlerTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandlerTest.kt new file mode 100644 index 0000000000..b9f6992504 --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/deeplink/DefaultYieldDeepLinkHandlerTest.kt @@ -0,0 +1,247 @@ +package com.tangem.features.feed.deeplink + +import arrow.core.Either +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY +import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.earn.PreselectedEarnType +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesProducer +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.domain.yield.supply.models.YieldSupplyAvailability +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetAvailabilityUseCase +import com.tangem.utils.logging.TangemLogger +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultYieldDeepLinkHandlerTest { + + private val appRouter: AppRouter = mockk() + private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase = mockk() + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk() + private val yieldSupplyGetAvailabilityUseCase: YieldSupplyGetAvailabilityUseCase = mockk() + + private val tokenId = "usd-coin" + private val networkId = "base" + + @BeforeEach + fun setUp() { + mockkObject(TangemLogger) + every { appRouter.push(any(), any()) } just Runs + } + + @Test + fun `missing token_id falls back to earn yield`() = runTest { + handle( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + queryParams = mapOf(NETWORK_ID_KEY to networkId), + ) + + verify { + appRouter.push( + AppRoute.Earn( + preselectedEarnType = PreselectedEarnType.Yield, + preselectedNetworkId = networkId, + ), + any(), + ) + } + } + + @Test + fun `missing network_id falls back to earn yield`() = runTest { + handle( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + queryParams = mapOf(TOKEN_ID_KEY to tokenId), + ) + + verify { + appRouter.push( + AppRoute.Earn( + preselectedEarnType = PreselectedEarnType.Yield, + preselectedNetworkId = null, + ), + any(), + ) + } + } + + @Test + fun `no selected wallet falls back to earn yield`() = runTest { + every { getSelectedWalletSyncUseCase() } returns Either.Left(mockk()) + + handle( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId), + ) + advanceUntilIdle() + + verify { + appRouter.push( + AppRoute.Earn( + preselectedEarnType = PreselectedEarnType.Yield, + preselectedNetworkId = networkId, + ), + any(), + ) + } + } + + @Test + fun `token not in wallet falls back to earn yield`() = runTest { + val walletId = UserWalletId("011") + val wallet = mockk { every { this@mockk.walletId } returns walletId } + every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet) + coEvery { + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + MultiWalletCryptoCurrenciesProducer.Params(walletId), + ) + } returns emptySet() + + handle( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId), + ) + advanceUntilIdle() + + verify { + appRouter.push( + AppRoute.Earn( + preselectedEarnType = PreselectedEarnType.Yield, + preselectedNetworkId = networkId, + ), + any(), + ) + } + } + + @Test + fun `eligible token with yield available pushes YieldSupplyEntry`() = runTest { + val walletId = UserWalletId("011") + val wallet = mockk { every { this@mockk.walletId } returns walletId } + val currency = mockEligibleCurrency() + every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet) + coEvery { + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + MultiWalletCryptoCurrenciesProducer.Params(walletId), + ) + } returns setOf(currency) + coEvery { yieldSupplyGetAvailabilityUseCase(currency) } returns + Either.Right(YieldSupplyAvailability.Available(apy = "2.66")) + + handle( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId), + ) + advanceUntilIdle() + + verify { + appRouter.push( + match { + it.userWalletId == walletId && it.cryptoCurrency === currency && it.apy == "2.66" + }, + any(), + ) + } + } + + @Test + fun `eligible token with yield unavailable falls back to earn yield`() = runTest { + val walletId = UserWalletId("011") + val wallet = mockk { every { this@mockk.walletId } returns walletId } + val currency = mockEligibleCurrency() + every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet) + coEvery { + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + MultiWalletCryptoCurrenciesProducer.Params(walletId), + ) + } returns setOf(currency) + coEvery { yieldSupplyGetAvailabilityUseCase(currency) } returns + Either.Right(YieldSupplyAvailability.Unavailable) + + handle( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId), + ) + advanceUntilIdle() + + verify { + appRouter.push( + AppRoute.Earn( + preselectedEarnType = PreselectedEarnType.Yield, + preselectedNetworkId = networkId, + ), + any(), + ) + } + } + + @Test + fun `eligible token with availability lookup error falls back to earn yield`() = runTest { + val walletId = UserWalletId("011") + val wallet = mockk { every { this@mockk.walletId } returns walletId } + val currency = mockEligibleCurrency() + every { getSelectedWalletSyncUseCase() } returns Either.Right(wallet) + coEvery { + multiWalletCryptoCurrenciesSupplier.getSyncOrNull( + MultiWalletCryptoCurrenciesProducer.Params(walletId), + ) + } returns setOf(currency) + coEvery { yieldSupplyGetAvailabilityUseCase(currency) } returns + Either.Left(IllegalStateException("api 500")) + + handle( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + queryParams = mapOf(TOKEN_ID_KEY to tokenId, NETWORK_ID_KEY to networkId), + ) + advanceUntilIdle() + + verify { + appRouter.push( + AppRoute.Earn( + preselectedEarnType = PreselectedEarnType.Yield, + preselectedNetworkId = networkId, + ), + any(), + ) + } + } + + /** Builds a [CryptoCurrency] mock whose `network.rawId` and `id.rawCurrencyId` match the test's + * [tokenId] / [networkId] — i.e. the supplier-lookup predicate inside the handler returns it. */ + private fun mockEligibleCurrency(): CryptoCurrency = mockk(relaxed = true) { + every { network } returns mockk(relaxed = true) { every { rawId } returns networkId } + every { id } returns mockk(relaxed = true) { + every { rawCurrencyId } returns CryptoCurrency.RawID(tokenId) + } + } + + private fun handle(scope: CoroutineScope, queryParams: Map) { + DefaultYieldDeepLinkHandler( + scope = scope, + queryParams = queryParams, + appRouter = appRouter, + getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + yieldSupplyGetAvailabilityUseCase = yieldSupplyGetAvailabilityUseCase, + ) + } +} \ No newline at end of file From 67ca192c16da5aaa78025e0445ef6f74d057d5af Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 4 May 2026 14:46:26 +0400 Subject: [PATCH 163/206] Updated on 2026-08-14 --- .../CardContextInterceptor.kt | 2 ++ .../domain/scanCard/LegacyScanProcessor.kt | 20 ++----------- .../repository/DefaultScanCardRepository.kt | 2 ++ .../sdk/impl/DefaultTangemSdkManager.kt | 30 ++++++++++++++----- .../domain/sdk/impl/MockTangemSdkManager.kt | 2 ++ .../DefaultUserWalletsListRepository.kt | 9 ++++-- domain/common/build.gradle.kts | 7 +++-- .../wallets/UserWalletsListRepository.kt | 6 +++- .../NonBiometricUnlockWalletUseCase.kt | 8 +++-- .../wallets/usecase/UnlockWalletUseCase.kt | 8 +++-- .../CreateWalletStartModel.kt | 5 +++- .../CreateWalletStartModelTest.kt | 5 +++- .../details/model/UserWalletListModel.kt | 6 ++-- .../CreateHardwareWalletModel.kt | 7 +++-- .../upgradewallet/UpgradeWalletModel.kt | 5 +++- .../intents/WalletWarningsClickIntents.kt | 8 ++--- .../welcome/impl/model/WelcomeModel.kt | 9 ++++-- libs/tangem-sdk-api/build.gradle.kts | 1 + .../com/tangem/sdk/api/TangemSdkManager.kt | 2 ++ 19 files changed, 90 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt index 24e95b0b93..bc13e2a3cd 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/paramsInterceptor/CardContextInterceptor.kt @@ -3,6 +3,7 @@ package com.tangem.tap.common.analytics.paramsInterceptor import com.tangem.core.analytics.api.ParamsInterceptor import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.event.SignIn import com.tangem.domain.card.analytics.IntroductionProcess import com.tangem.domain.card.analytics.ParamCardCurrencyConverter @@ -31,6 +32,7 @@ class CardContextInterceptor( is IntroductionProcess.ButtonScanCardLegacy, is SignIn.ScreenOpened, is SignIn.ButtonAddWallet, + is Basic.CardWasScanned, -> false is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll else -> true diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt index c26c10205c..de16e632ae 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/LegacyScanProcessor.kt @@ -7,11 +7,8 @@ import com.tangem.common.doOnFailure import com.tangem.common.doOnSuccess import com.tangem.common.routing.AppRoute import com.tangem.common.routing.AppRouter -import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.models.AnalyticsEvent 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.analytics.utils.TrackingContextProxy import com.tangem.core.decompose.di.GlobalUiMessageSender @@ -28,7 +25,6 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.onboarding.WasTwinsOnboardingShownUseCase import com.tangem.sdk.extensions.localizedDescriptionRes -import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor import com.tangem.tap.features.onboarding.OnboardingHelper import com.tangem.tap.mainScope import com.tangem.tap.tangemSdkManager @@ -61,6 +57,7 @@ internal class LegacyScanProcessor @Inject constructor( cardId = cardId, allowsRequestAccessCodeFromRepository = allowsRequestAccessCodeFromRepository, shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated, + source = analyticsSource, ) .doOnFailure { error -> onScanFailure(analyticsSource = analyticsSource, error = error, onFailure = {}, onCancel = {}) @@ -85,10 +82,9 @@ internal class LegacyScanProcessor @Inject constructor( val result = tangemSdkManager.scanProduct( cardId = cardId, shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated, + source = analyticsSource, ) - val analyticsEvent = Basic.CardWasScanned(analyticsSource) - result .doOnFailure { error -> scanFailsCounter.onScanFailure( @@ -111,8 +107,6 @@ internal class LegacyScanProcessor @Inject constructor( scanFailsCounter.reset() tangemSdkManager.changeDisplayedCardIdNumbersCount(scanResponse) - sendAnalytics(analyticsEvent, scanResponse) - onScanSuccess( scanResponse = scanResponse, onProgressStateChange = onProgressStateChange, @@ -122,16 +116,6 @@ internal class LegacyScanProcessor @Inject constructor( } } - private fun sendAnalytics(analyticsEvent: AnalyticsEvent, scanResponse: ScanResponse) { - // this workaround needed to send CardWasScannedEvent without adding a context - val interceptor = CardContextInterceptor(scanResponse) - val params = analyticsEvent.params.toMutableMap() - interceptor.intercept(params) - analyticsEvent.params = params.toMap() - - Analytics.send(analyticsEvent) - } - private suspend inline fun onScanFailure( analyticsSource: AnalyticsParam.ScreensSources, error: TangemError, diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/repository/DefaultScanCardRepository.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/repository/DefaultScanCardRepository.kt index ecd2cffeb0..f0ddd9fe6d 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/repository/DefaultScanCardRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/repository/DefaultScanCardRepository.kt @@ -18,11 +18,13 @@ internal class DefaultScanCardRepository( allowRequestAccessCodeFromStorage: Boolean, shouldCheckIsAlreadyActivated: Boolean, ): ScanResponse { + @Suppress("UnreachableCode") return when ( val result = tangemSdkManager.scanProduct( cardId = cardId, allowsRequestAccessCodeFromRepository = allowRequestAccessCodeFromStorage, shouldCheckIsAlreadyActivated = shouldCheckIsAlreadyActivated, + source = TODO("Fix when enable NEW_CARD_SCANNING_ENABLED"), ) ) { is CompletionResult.Success -> result.data diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 6211ed6b11..c4a6e936fc 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -18,6 +18,8 @@ import com.tangem.common.usersCode.UserCodeRepository import com.tangem.core.analytics.Analytics import com.tangem.core.analytics.api.AnalyticsErrorHandler import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic import com.tangem.core.res.getStringSafe import com.tangem.crypto.bip39.DefaultMnemonic import com.tangem.crypto.hdWallet.DerivationPath @@ -25,13 +27,13 @@ import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.card.repository.CardRepository import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.visa.model.* import com.tangem.domain.wallets.derivations.derivationStyleProvider -import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.features.onboarding.v2.OnboardingV2FeatureToggles import com.tangem.operations.ScanTask import com.tangem.operations.derivation.DerivationTaskResponse @@ -46,6 +48,7 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.visa.VisaCardActivationResponse import com.tangem.sdk.api.visa.VisaCardActivationTaskMode import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent +import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor import com.tangem.tap.domain.tasks.product.* import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask @@ -135,6 +138,7 @@ internal class DefaultTangemSdkManager( messageRes: Int?, allowsRequestAccessCodeFromRepository: Boolean, shouldCheckIsAlreadyActivated: Boolean, + source: AnalyticsParam.ScreensSources, ): CompletionResult { val message = Message(resources.getStringSafe(messageRes ?: R.string.initial_message_scan_header)) return coroutineScope { @@ -152,7 +156,7 @@ internal class DefaultTangemSdkManager( ), cardId = cardId, initialMessage = message, - ).also { sendScanResultsToAnalytics(it) } + ).also { sendAnalytics(result = it, source = source) } } } @@ -225,12 +229,24 @@ internal class DefaultTangemSdkManager( .doOnResult { tangemSdk.config.setupForProduct(ProductType.ANY) } } - private fun sendScanResultsToAnalytics(result: CompletionResult) { - if (result is CompletionResult.Failure) { - (result.error as? TangemSdkError)?.let { error -> - Analytics.sendErrorEvent(TangemSdkErrorEvent(error)) + private fun sendAnalytics(result: CompletionResult, source: AnalyticsParam.ScreensSources) { + result + .doOnSuccess { scanResponse -> + // We don't use the standard analytics event enrichment mechanism with card context params here, + // because we've just scanned a new card that may not yet be selected as the current one + val analyticsEvent = Basic.CardWasScanned(source) + val interceptor = CardContextInterceptor(scanResponse) + val params = analyticsEvent.params.toMutableMap() + interceptor.intercept(params) + analyticsEvent.params = params.toMap() + + Analytics.send(event = analyticsEvent) + } + .doOnFailure { tangemError -> + (tangemError as? TangemSdkError)?.let { error -> + Analytics.sendErrorEvent(TangemSdkErrorEvent(error)) + } } - } } override suspend fun derivePublicKeys( diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt index bc15466208..0ed38d78c4 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt @@ -14,6 +14,7 @@ import com.tangem.common.core.TangemSdkError import com.tangem.common.core.UserCodeRequestPolicy import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.services.InMemoryStorage +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.res.getStringSafe import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey @@ -63,6 +64,7 @@ class MockTangemSdkManager( messageRes: Int?, allowsRequestAccessCodeFromRepository: Boolean, shouldCheckIsAlreadyActivated: Boolean, + source: AnalyticsParam.ScreensSources, ): CompletionResult { if (!MockProvider.isPreset) { val activity = foregroundActivityObserver.foregroundActivity diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 54a235ba71..333d1170d2 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -302,11 +302,14 @@ internal class DefaultUserWalletsListRepository( } val scanResponse = unlockMethod.scanResponse ?: run { - when (val res = tangemSdkManagerProvider().scanProduct( + val tangemSdkManager = tangemSdkManagerProvider() + val result = tangemSdkManager.scanProduct( shouldCheckIsAlreadyActivated = false, - )) { + source = unlockMethod.source, + ) + when (result) { is CompletionResult.Failure -> raise(UnlockWalletError.UserCancelled) - is CompletionResult.Success -> res.data + is CompletionResult.Success -> result.data } } diff --git a/domain/common/build.gradle.kts b/domain/common/build.gradle.kts index 20e637a0a5..e1bfa04f51 100644 --- a/domain/common/build.gradle.kts +++ b/domain/common/build.gradle.kts @@ -5,13 +5,14 @@ plugins { } dependencies { - api(deps.kotlin.coroutines) api(deps.arrow.core) api(deps.arrow.fx) - api(projects.domain.models) - + api(deps.kotlin.coroutines) implementation(deps.kotlin.serialization) + api(projects.core.analytics.models) + api(projects.domain.models) + testImplementation(deps.test.coroutine) testImplementation(deps.test.junit) testImplementation(deps.test.mockk) diff --git a/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletsListRepository.kt b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletsListRepository.kt index c9f9aecb00..c5e809f26b 100644 --- a/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletsListRepository.kt +++ b/domain/common/src/main/java/com/tangem/domain/common/wallets/UserWalletsListRepository.kt @@ -1,6 +1,7 @@ package com.tangem.domain.common.wallets import arrow.core.Either +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.common.wallets.error.* import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWallet @@ -145,7 +146,10 @@ interface UserWalletsListRepository { sealed class UnlockMethod { data object Biometric : UnlockMethod() data object AccessCode : UnlockMethod() - data class Scan(val scanResponse: ScanResponse? = null) : UnlockMethod() + data class Scan( + val scanResponse: ScanResponse? = null, + val source: AnalyticsParam.ScreensSources, + ) : UnlockMethod() } } diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt index d225546c2d..d55d30a84f 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/NonBiometricUnlockWalletUseCase.kt @@ -2,6 +2,7 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either import arrow.core.raise.either +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.UnlockWalletError import com.tangem.domain.models.wallet.UserWallet @@ -22,7 +23,10 @@ class NonBiometricUnlockWalletUseCase( private val walletsRepository: WalletsRepository, ) { - suspend operator fun invoke(userWalletId: UserWalletId): Either = either { + suspend operator fun invoke( + userWalletId: UserWalletId, + source: AnalyticsParam.ScreensSources, + ): Either = either { val userWallet = userWalletsListRepository.userWalletsSync() .find { it.walletId == userWalletId } ?: raise(UnlockWalletError.UserWalletNotFound) @@ -32,7 +36,7 @@ class NonBiometricUnlockWalletUseCase( } val method = when (userWallet) { - is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan() + is UserWallet.Cold -> UserWalletsListRepository.UnlockMethod.Scan(scanResponse = null, source = source) is UserWallet.Hot -> UserWalletsListRepository.UnlockMethod.AccessCode } diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt index 61d4c4e10c..7d92dd628e 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/UnlockWalletUseCase.kt @@ -2,6 +2,7 @@ package com.tangem.domain.wallets.usecase import arrow.core.Either import arrow.core.raise.either +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.UnlockWalletError import com.tangem.domain.models.wallet.UserWalletId @@ -19,12 +20,15 @@ class UnlockWalletUseCase( private val nonBiometricUnlockWalletUseCase: NonBiometricUnlockWalletUseCase, ) { - suspend operator fun invoke(userWalletId: UserWalletId): Either = either { + suspend operator fun invoke( + userWalletId: UserWalletId, + source: AnalyticsParam.ScreensSources, + ): Either = either { userWalletsListRepository.unlock(userWalletId, UserWalletsListRepository.UnlockMethod.Biometric) .mapLeft { error -> when (error) { UnlockWalletError.AlreadyUnlocked -> Unit - else -> nonBiometricUnlockWalletUseCase(userWalletId).bind() + else -> nonBiometricUnlockWalletUseCase(userWalletId, source).bind() } } } diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 9a2539c546..71b570076c 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -227,7 +227,10 @@ internal class CreateWalletStartModel @Inject constructor( is SaveWalletError.WalletAlreadySaved -> { userWalletsListRepository.unlock( userWalletId = userWallet.walletId, - unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan( + scanResponse = scanResponse, + source = AnalyticsParam.ScreensSources.Intro, + ), ).onRight { appRouter.replaceAll(AppRoute.Wallet) } diff --git a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt index 28520a0603..b1fcae0b1e 100644 --- a/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt +++ b/features/create-wallet-start/impl/src/test/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModelTest.kt @@ -421,7 +421,10 @@ internal class CreateWalletStartModelTest { coVerify { userWalletsListRepository.unlock( userWalletId = testUserWalletId, - unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(testScanResponse), + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan( + scanResponse = testScanResponse, + source = AnalyticsParam.ScreensSources.Intro, + ), ) } verify { appRouter.replaceAll(routes = arrayOf(AppRoute.Wallet), onComplete = any()) } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt index ac74369033..248d94b21a 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/UserWalletListModel.kt @@ -13,22 +13,22 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents import com.tangem.domain.wallets.usecase.ApplyUserWalletListSortingUseCase import com.tangem.domain.wallets.usecase.UnlockWalletUseCase import com.tangem.features.details.entity.UserWalletListUM import com.tangem.features.details.entity.WalletReorderUM import com.tangem.features.details.impl.R -import com.tangem.domain.settings.HotWalletRestrictionManager import com.tangem.features.details.utils.UserWalletSaver import com.tangem.features.wallet.utils.UserWalletsFetcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject @Suppress("LongParameterList") @@ -112,7 +112,7 @@ internal class UserWalletListModel @Inject constructor( private fun onWalletClicked(userWalletId: UserWalletId) { modelScope.launch { - unlockWalletUseCase(userWalletId) + unlockWalletUseCase(userWalletId, AnalyticsParam.ScreensSources.Settings) .onRight { router.push(AppRoute.WalletSettings(userWalletId)) } .onLeft { error -> TangemLogger.e("Failed to unlock wallet $userWalletId: $error") diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt index c11626ce87..89da07442c 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/createhardwarewallet/CreateHardwareWalletModel.kt @@ -29,12 +29,12 @@ import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.features.hotwallet.createhardwarewallet.entity.CreateHardwareWalletUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import com.tangem.utils.logging.TangemLogger import javax.inject.Inject private const val HIDE_PROGRESS_DELAY = 400L @@ -181,7 +181,10 @@ internal class CreateHardwareWalletModel @Inject constructor( ) userWalletsListRepository.unlock( userWalletId = walletId, - unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan( + scanResponse = scanResponse, + source = AnalyticsParam.ScreensSources.AddNew, + ), ).onRight { router.replaceAll(AppRoute.Wallet) } diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt index 5b9efafb88..09f0e2f4a2 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/upgradewallet/UpgradeWalletModel.kt @@ -115,7 +115,10 @@ internal class UpgradeWalletModel @Inject constructor( ) tangemSdkManager - .scanProduct(shouldCheckIsAlreadyActivated = true) + .scanProduct( + shouldCheckIsAlreadyActivated = true, + source = AnalyticsParam.ScreensSources.Upgrade, + ) .doOnSuccess { scanResponse -> checkIsWalletSuitableToBeUsedAsUpgrade(scanResponse = scanResponse) { delay(DELAY_SDK_DIALOG_CLOSE) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt index 7c66a4acc4..5eafa28841 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletWarningsClickIntents.kt @@ -1,7 +1,6 @@ package com.tangem.feature.wallet.child.wallet.model.intents import arrow.core.getOrElse -import com.tangem.utils.logging.TangemLogger import com.tangem.common.routing.AppRoute.* import com.tangem.common.routing.AppRouter import com.tangem.common.ui.notifications.NotificationId @@ -14,6 +13,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.review.ReviewManager import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase import com.tangem.domain.card.SetCardWasScannedUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.feedback.GetWalletMetaInfoUseCase @@ -41,16 +41,15 @@ import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.Program import com.tangem.domain.tokens.model.analytics.PromoAnalyticsEvent.PromotionBannerClicked import com.tangem.domain.tokens.model.details.NavigationAction -import com.tangem.domain.assetsdiscovery.usecase.AcknowledgeAssetsDiscoveryCompletionUseCase import com.tangem.domain.wallets.usecase.* import com.tangem.feature.wallet.presentation.wallet.analytics.WalletScreenAnalyticsEvent -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.WalletStateController import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnalyticEvents import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -174,7 +173,6 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( } override fun onGenerateMissedAddressesClick(missedAddressCurrencies: List) { - analyticsEventHandler.send(Basic.CardWasScanned(AnalyticsParam.ScreensSources.Main)) analyticsEventHandler.send(MainScreen.NoticeScanYourCardTapped()) modelScope.launch { @@ -199,7 +197,7 @@ internal class WalletWarningsClickIntentsImplementor @Inject constructor( userWalletsListRepository.unlockAllWallets() .onLeft { val selectedUserWalletId = stateHolder.getSelectedWalletId() - nonBiometricUnlockWalletUseCase(selectedUserWalletId) + nonBiometricUnlockWalletUseCase(selectedUserWalletId, AnalyticsParam.ScreensSources.Main) .onLeft { error -> error.handle( onAlreadyUnlocked = {}, diff --git a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt index 0c671bec95..806de55c70 100644 --- a/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt +++ b/features/welcome/impl/src/main/kotlin/com/tangem/features/welcome/impl/model/WelcomeModel.kt @@ -208,8 +208,11 @@ internal class WelcomeModel @Inject constructor( ).onLeft { error -> if (error is SaveWalletError.WalletAlreadySaved) { userWalletsListRepository.unlock( - userWallet.walletId, - unlockMethod = UserWalletsListRepository.UnlockMethod.Scan(scanResponse), + userWalletId = userWallet.walletId, + unlockMethod = UserWalletsListRepository.UnlockMethod.Scan( + scanResponse = scanResponse, + source = AnalyticsParam.ScreensSources.SignIn, + ), ).onRight { userWalletsListRepository.select(userWallet.walletId) router.replaceAll(AppRoute.Wallet) @@ -274,7 +277,7 @@ internal class WelcomeModel @Inject constructor( } private suspend fun nonBiometricUnlockWallet(userWalletId: UserWalletId) { - nonBiometricUnlockWalletUseCase(userWalletId) + nonBiometricUnlockWalletUseCase(userWalletId, AnalyticsParam.ScreensSources.SignIn) .onRight { routedOut = true userWalletsListRepository.select(userWalletId) diff --git a/libs/tangem-sdk-api/build.gradle.kts b/libs/tangem-sdk-api/build.gradle.kts index 1a6d4da84d..813c8f51e6 100644 --- a/libs/tangem-sdk-api/build.gradle.kts +++ b/libs/tangem-sdk-api/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(projects.domain.models) implementation(projects.domain.visa.models) + api(projects.core.analytics.models) implementation(projects.core.configToggles) implementation(projects.core.res) diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt index a7697062bd..9ea0e0b15a 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt @@ -12,6 +12,7 @@ import com.tangem.common.core.CardSessionRunnable import com.tangem.common.core.UserCodeRequestPolicy import com.tangem.common.extensions.ByteArrayKey import com.tangem.common.services.secure.SecureStorage +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.crypto.hdWallet.DerivationPath import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.models.scan.CardDTO @@ -57,6 +58,7 @@ interface TangemSdkManager { messageRes: Int? = null, allowsRequestAccessCodeFromRepository: Boolean = false, shouldCheckIsAlreadyActivated: Boolean, + source: AnalyticsParam.ScreensSources, ): CompletionResult suspend fun createProductWallet( From 7b6fc18a8f438597c190889fd24d327bb25cfea1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 5 May 2026 18:32:39 +0300 Subject: [PATCH 164/206] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 7 +++ .../com/tangem/scenarios/BaseScenarios.kt | 4 -- .../com/tangem/scenarios/SwapScenarios.kt | 39 ++++----------- .../tangem/screens/MainScreenPageObject.kt | 6 +-- .../com/tangem/screens/SwapTokenPageObject.kt | 2 +- .../tests/swap/SwapChooseTokenScreenTest.kt | 16 ------- .../com/tangem/tests/swap/SwapStoriesTest.kt | 6 +-- .../tangem/tests/swap/SwapTokenScreenTest.kt | 42 +++++++++------- .../tests/swap/SwapTokenScreenWarningsTest.kt | 48 +++++++++---------- .../tap/data/MockAwareTangemPayStorage.kt | 6 +-- .../stories/inner/StoriesProgressBar.kt | 4 +- .../MockAwareOnboardingRepository.kt | 12 ++--- 12 files changed, 79 insertions(+), 113 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 6acf072e3c..3b22023f6e 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -27,6 +27,7 @@ import com.tangem.datasource.utils.WireMockRedirectInterceptor import com.tangem.domain.promo.PromoRepository import com.tangem.domain.promo.models.PromoId import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase +import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.tap.MainActivity import dagger.hilt.android.testing.HiltAndroidRule import io.qameta.allure.kotlin.Allure @@ -129,6 +130,12 @@ abstract class BaseTestCase : TestCase( value = false ) } + appPreferencesStore.editData { mutablePreferences -> + mutablePreferences.set( + key = PreferencesKeys.getShouldShowInitialPermissionScreen(PUSH_PERMISSION), + value = false + ) + } promoRepository.setNeverToShowWalletPromo(PromoId.Sepa) } apiEnvironmentRule.setup(apiConfigsManager) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt index 22a818f96c..49e40ea9f1 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/BaseScenarios.kt @@ -128,10 +128,6 @@ fun BaseTestCase.synchronizeAddresses( onMainScreen { totalBalanceText.assert(!hasText(DASH_SIGN)) } } } - - step("Expand 'Main account' to reveal tokens") { - onMainScreen { mainAccount().performClick() } - } } fun BaseTestCase.openDeviceSettingsScreen() { diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index 43fdc8264d..748c45459c 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -11,20 +11,17 @@ import io.qameta.allure.kotlin.Allure.step import com.tangem.common.ui.R as CommonUiR private val firstStoryIndex = 0 -private val firstStoryTitle = getResourceString(CommonUiR.string.swap_story_first_title) -private val firstStorySubtitle = getResourceString(CommonUiR.string.swap_story_first_subtitle) +private val firstStoryTitle = getResourceString(CommonUiR.string.swap_story_first_title_v2) +private val firstStorySubtitle = getResourceString(CommonUiR.string.swap_story_first_subtitle_v2) private val secondStoryIndex = 1 -private val secondStoryTitle = getResourceString(CommonUiR.string.swap_story_second_title) -private val secondStorySubtitle = getResourceString(CommonUiR.string.swap_story_second_subtitle) +private val secondStoryTitle = getResourceString(CommonUiR.string.swap_story_second_title_v2) +private val secondStorySubtitle = getResourceString(CommonUiR.string.swap_story_second_subtitle_v2) private val thirdStoryIndex = 2 -private val thirdStoryTitle = getResourceString(CommonUiR.string.swap_story_third_title) -private val thirdStorySubtitle = getResourceString(CommonUiR.string.swap_story_third_subtitle) +private val thirdStoryTitle = getResourceString(CommonUiR.string.swap_story_third_title_v2) +private val thirdStorySubtitle = getResourceString(CommonUiR.string.swap_story_third_subtitle_v2) private val forthStoryIndex = 3 -private val forthStoryTitle = getResourceString(CommonUiR.string.swap_story_forth_title) -private val forthStorySubtitle = getResourceString(CommonUiR.string.swap_story_forth_subtitle) -private val fifthStoryIndex = 4 -private val fifthStoryTitle = getResourceString(CommonUiR.string.swap_story_fifth_title) -private val fifthStorySubtitle = getResourceString(CommonUiR.string.swap_story_fifth_subtitle) +private val forthStoryTitle = getResourceString(CommonUiR.string.swap_story_forth_title_v2) +private val forthStorySubtitle = getResourceString(CommonUiR.string.swap_story_forth_subtitle_v2) fun BaseTestCase.openSwapScreen( from: SwapEntryPoint, @@ -120,26 +117,6 @@ fun BaseTestCase.checkStoriesChanges() { storySubtitle = forthStorySubtitle ) } - step("Click on right side") { - onSwapStoriesScreen { container.performTouchInput { click(centerRight) } } - } - step("Check title and subtitle for story №${fifthStoryIndex + 1}") { - checkStoriesContent( - storyIndex = fifthStoryIndex, - storyTitle = fifthStoryTitle, - storySubtitle = fifthStorySubtitle - ) - } - step("Click on left side") { - onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } } - } - step("Check title and subtitle for story №${forthStoryIndex + 1}") { - checkStoriesContent( - storyIndex = forthStoryIndex, - storyTitle = forthStoryTitle, - storySubtitle = forthStorySubtitle - ) - } step("Click on left side") { onSwapStoriesScreen { container.performTouchInput { click(centerLeft) } } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index abdbaeb2a7..d281e57344 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -244,7 +244,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) fun tokenWithTitleAndAddress(tokenTitle: String): KNode { return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) - hasAnyDescendant(withText(tokenTitle)) + hasText(tokenTitle) useUnmergedTree = true }.child { hasTestTag(TokenElementsTestTags.TOKEN_FIAT_AMOUNT) @@ -256,7 +256,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode { return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) - hasAnyDescendant(withText(tokenTitle)) + hasText(tokenTitle) useUnmergedTree = true }.child { hasTestTag(TokenElementsTestTags.TOKEN_CUSTOM_DERIVATION_ICON) @@ -297,7 +297,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode { return lazyList.childWith { hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) - hasAnyDescendant(withText(tokenTitle)) + hasText(tokenTitle) hasLazyListItemPosition(index) useUnmergedTree = true }.child { diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index 73c09bf564..ce2c554f0f 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -143,7 +143,7 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) val youSwapBlock: KNode = child { hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER) - hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title))) + hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2))) hasAnyDescendant(withTestTag(SwapTokenScreenTestTags.BALANCE)) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt index def2f12334..426cb52d6e 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapChooseTokenScreenTest.kt @@ -30,7 +30,6 @@ class SwapChooseTokenScreenTest : BaseTestCase() { @Test fun checkAvailableToSwapTokensListTest() { val tokenTitle = "Polygon" - val inputAmount = "100" val ethereum = "Ethereum" val polExMatic = "POL (ex-MATIC)" val bitcoin = "Bitcoin" @@ -66,13 +65,6 @@ class SwapChooseTokenScreenTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } - step("Input swap amount = '$inputAmount'") { - waitForIdle() - onSwapTokenScreen { - textInput.clickWithAssertion() - textInput.performTextReplacement(inputAmount) - } - } step("Click on 'Select token' icon") { onSwapTokenScreen { swapSelectTokenIcon.performClick() } } @@ -102,7 +94,6 @@ class SwapChooseTokenScreenTest : BaseTestCase() { @Test fun checkSearchOnSwapChooseTokenScreenTest() { val tokenTitle = "Polygon" - val inputAmount = "100" val ethereum = "Ethereum" val polExMatic = "POL (ex-MATIC)" val polExMaticSymbol = "POL" @@ -129,13 +120,6 @@ class SwapChooseTokenScreenTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } - step("Input swap amount = '$inputAmount'") { - waitForIdle() - onSwapTokenScreen { - textInput.clickWithAssertion() - textInput.performTextReplacement(inputAmount) - } - } step("Click on 'Choose token' button") { onSwapTokenScreen { chooseTokenButton.performClick() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt index 9820b6fe9b..13bc7aba06 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapStoriesTest.kt @@ -117,7 +117,7 @@ class SwapStoriesTest : BaseTestCase() { @DisplayName("Check unavailable swap stories on 'Main' screen") @Test fun checkUnavailableSwapStoriesOnMainScreen() { - val scenarioName = "stories_first_time_swap" + val scenarioName = "stories_first_time_swap_v2" val scenarioErrorState = "Error" val packageName = getTargetContext().packageName @@ -168,7 +168,7 @@ class SwapStoriesTest : BaseTestCase() { @DisplayName("Check unavailable swap stories on 'Token details' screen") @Test fun checkUnavailableSwapStoriesOnTokenDetailsScreen() { - val scenarioName = "stories_first_time_swap" + val scenarioName = "stories_first_time_swap_v2" val scenarioErrorState = "Error" val packageName = getTargetContext().packageName val tokenName = "Ethereum" @@ -224,7 +224,7 @@ class SwapStoriesTest : BaseTestCase() { @DisplayName("Check unavailable swap stories on 'Markets' token details screen") @Test fun checkUnavailableSwapStoriesOnMarketsTokenDetailsScreen() { - val scenarioName = "stories_first_time_swap" + val scenarioName = "stories_first_time_swap_v2" val scenarioErrorState = "Error" val packageName = getTargetContext().packageName val tokenName = "Ethereum" diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt index 0336d3895d..94092b1e2f 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt @@ -68,6 +68,9 @@ class SwapTokenScreenTest : BaseTestCase() { } } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -75,9 +78,6 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Assert input amount = '$inputAmount'") { onSwapTokenScreen { textInput.assertTextEquals(inputAmount) } } @@ -121,6 +121,7 @@ class SwapTokenScreenTest : BaseTestCase() { @Test fun networkErrorSwapTest() { val tokenTitle = "Polygon" + val receiveTokenName = "Ethereum" setupHooks( additionalAfterSection = { @@ -154,6 +155,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert 'Swap' screen title is displayed") { onSwapTokenScreen { title.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Assert error notification title is displayed") { onSwapTokenScreen { waitForIdle() @@ -212,6 +216,9 @@ class SwapTokenScreenTest : BaseTestCase() { } } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -222,9 +229,6 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert input amount = '$inputAmount'") { onSwapTokenScreen { textInput.assertTextEquals(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Assert receive amount is displayed") { onSwapTokenScreen { flakySafely(WAIT_UNTIL_TIMEOUT) { @@ -461,8 +465,12 @@ class SwapTokenScreenTest : BaseTestCase() { onSwapTokenScreen { receiveTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } } step("Click on 'Swap tokens on screen' button") { - onSwapTokenScreen { replaceTokensButton.performClick() } - waitForIdle() + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { + replaceTokensButton.assertIsEnabled() + replaceTokensButton.performClick() + } + } } step("Assert new swap token symbol: '$receiveTokenSymbol' is displayed") { onSwapTokenScreen { swapTokenSymbol(receiveTokenSymbol).assertIsDisplayed() } @@ -557,6 +565,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -564,9 +575,6 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Select '$market' fee type") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { selectFeeType(FeeType.Market, selectedFeeAmount = marketFeeAmount) @@ -623,6 +631,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -630,9 +641,6 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Select '$marketFeeType' fee type") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { selectFeeType(feeType = FeeType.Market, feeAmount) @@ -703,6 +711,9 @@ class SwapTokenScreenTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -710,9 +721,6 @@ class SwapTokenScreenTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Assert 'Swap' button is enabled") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onSwapTokenScreen { swapButton.assertIsEnabled() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt index 3b220ce5d5..51e2368332 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt @@ -60,6 +60,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenTitle) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -67,9 +70,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenTitle) - } step("Assert 'Insufficient funds' error is displayed") { waitForIdle() onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } @@ -125,6 +125,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(networkName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -132,9 +135,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(networkName) - } step("Check 'Unable to cover '$networkName' fee notification") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { chackUnableToCoverFeeNotification(networkName = networkName, currencySymbol = currencySymbol) @@ -204,6 +204,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -211,9 +214,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Assert fiat amount with warning is displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { onSwapTokenScreen { @@ -293,6 +293,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -300,9 +303,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Assert fiat amount with warning is displayed") { onSwapTokenScreen { receiveFiatAmount.assertTextContains("%", substring = true) } } @@ -381,6 +381,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -388,9 +391,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Assert 'Invalid amount' warning is not displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( @@ -450,6 +450,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -457,9 +460,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Assert 'Invalid amount' warning is not displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( @@ -520,6 +520,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -527,9 +530,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Assert 'Invalid amount' warning is not displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( @@ -589,6 +589,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { step("Assert 'You swap' block is displayed") { onSwapTokenScreen { youSwapBlock.assertIsDisplayed() } } + step("Choose receive token") { + chooseReceiveToken(receiveTokenName) + } step("Input swap amount = '$inputAmount'") { waitForIdle() onSwapTokenScreen { @@ -596,9 +599,6 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { textInput.performTextReplacement(inputAmount) } } - step("Choose receive token") { - chooseReceiveToken(receiveTokenName) - } step("Assert 'Invalid amount' warning is displayed") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { checkSwapWarning( diff --git a/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt index eabe4ccda7..baea0ab1a5 100644 --- a/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt +++ b/app/src/mocked/java/com/tangem/tap/data/MockAwareTangemPayStorage.kt @@ -84,10 +84,8 @@ internal class MockAwareTangemPayStorage @Inject constructor( override suspend fun storeCheckCustomerWalletResult(userWalletId: UserWalletId, isPaeraCustomer: Boolean) = real.storeCheckCustomerWalletResult(userWalletId, isPaeraCustomer) - override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? { - if (isMockMode) return true - return real.checkCustomerWalletResult(userWalletId) - } + override suspend fun checkCustomerWalletResult(userWalletId: UserWalletId): Boolean? = + real.checkCustomerWalletResult(userWalletId) override suspend fun storeActiveWithdrawOrderId(userWalletId: UserWalletId, orderId: String) = real.storeActiveWithdrawOrderId(userWalletId, orderId) diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesProgressBar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesProgressBar.kt index 00592f5bf2..0ef1136197 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesProgressBar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesProgressBar.kt @@ -96,14 +96,14 @@ fun StoriesProgressBar( .height(2.dp) .weight(1f) .clip(RoundedCornerShape(2.dp)) - .background(TangemColorPalette.White.copy(alpha = .2f)), + .background(TangemColorPalette.White.copy(alpha = .2f)) + .testTag(SwapStoriesScreenTestTags.PROGRESS_BAR_ITEM), ) { Box( modifier = Modifier .clip(RoundedCornerShape(2.dp)) .background(TangemColorPalette.White) .fillMaxHeight() - .testTag(SwapStoriesScreenTestTags.PROGRESS_BAR_ITEM) .let { modifier -> when (index) { currentStep -> modifier.fillMaxWidth(progress.value) diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt index 9bc64e5bcf..38ccbe554a 100644 --- a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt @@ -71,15 +71,11 @@ internal class MockAwareOnboardingRepository @Inject constructor( override suspend fun hasTangemPayInWallet(userWalletId: UserWalletId): Either = real.hasTangemPayInWallet(userWalletId) - override suspend fun checkCustomerEligibility(): List { - if (isMockMode) return listOf(TangemPayEligibilityType.DETAILS) - return real.checkCustomerEligibility() - } + override suspend fun checkCustomerEligibility(): List = + real.checkCustomerEligibility() - override suspend fun getCustomerEligibility(): List { - if (isMockMode) return listOf(TangemPayEligibilityType.DETAILS) - return real.getCustomerEligibility() - } + override suspend fun getCustomerEligibility(): List = + real.getCustomerEligibility() override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? = real.getSavedCustomerInfo(userWalletId) From 14ac9e6f308ed2dec03de3f25e2e5cc342c12660 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 6 May 2026 01:37:16 -0700 Subject: [PATCH 165/206] Updated on 2026-08-14 --- .../tangempay/limit/setup/TangemPayCardLimitSetupModel.kt | 2 +- .../tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index d832e12eea..487e568445 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -113,7 +113,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( } private fun onAmountChange(newValue: String) { - if (newValue.toBigDecimalOrNull() == null) return + if (newValue.isNotEmpty() && newValue.toBigDecimalOrNull() == null) return uiState.update { state -> state.copy( amountFieldModel = state.amountFieldModel.copy(value = newValue), diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index b99ecc5bb7..f9a1defa96 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -101,6 +101,7 @@ internal class TangemPayCardLimitSetupModelTest { ) { val model = createModel() + model.uiState.value.amountFieldModel.onValueChange("100") model.uiState.value.amountFieldModel.onValueChange(amount) assertThat(model.uiState.value.isSubmitButtonEnabled).isEqualTo(expectedEnabled) @@ -149,7 +150,7 @@ internal class TangemPayCardLimitSetupModelTest { Arguments.of("100", true), Arguments.of("-1", false), Arguments.of("", false), - Arguments.of("abc", false), + Arguments.of("abc", true), Arguments.of("1001", false), ) } \ No newline at end of file From c7e2e7876ef03cc3f1ee54fbdfbc246b73b60426 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 6 May 2026 11:39:05 +0300 Subject: [PATCH 166/206] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 71f159b32c..fa3065b6c9 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -57,7 +57,7 @@ }, { "name": "ADD_AND_MANAGE_TOKENS_ENABLED", - "version": "undefined" + "version": "5.38" }, { "name": "WALLET_CONNECT_BITCOIN_ENABLED", From e7edce0cd88be9f7858b3ba86f0036a1688292c7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 6 May 2026 12:44:09 +0400 Subject: [PATCH 167/206] Updated on 2026-08-14 --- .../analytics/PortfolioAnalyticsEvent.kt | 14 +++ .../managetokens/model/AddAndManageModel.kt | 5 + .../intents/WalletContentClickIntents.kt | 2 + .../model/AddAndManageModelTest.kt | 101 ++++++++++++++++++ .../WalletContentClickIntentsAnalyticsTest.kt | 83 ++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/analytics/PortfolioAnalyticsEvent.kt create mode 100644 features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModelTest.kt create mode 100644 features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntentsAnalyticsTest.kt diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/analytics/PortfolioAnalyticsEvent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/analytics/PortfolioAnalyticsEvent.kt new file mode 100644 index 0000000000..e69e15866b --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/analytics/PortfolioAnalyticsEvent.kt @@ -0,0 +1,14 @@ +package com.tangem.feature.wallet.child.managetokens.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent + +internal sealed class PortfolioAnalyticsEvent( + event: String, +) : AnalyticsEvent(category = "Portfolio", event = event) { + + class ButtonAddManage : PortfolioAnalyticsEvent(event = "Button - Add Manage") + + class ButtonAddTokens : PortfolioAnalyticsEvent(event = "Button - Add tokens") + + class ButtonOrganizeTokens : PortfolioAnalyticsEvent(event = "Button - Organize Tokens") +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt index 9600a357f8..ce64ceba7a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModel.kt @@ -3,11 +3,13 @@ package com.tangem.feature.wallet.child.managetokens.model import com.arkivanov.decompose.router.slot.SlotNavigation import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.models.account.AccountId import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent +import com.tangem.feature.wallet.child.managetokens.analytics.PortfolioAnalyticsEvent import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController @@ -21,6 +23,7 @@ internal class AddAndManageModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val portfolioFetcherFactory: PortfolioFetcher.Factory, + private val analyticsEventHandler: AnalyticsEventHandler, val portfolioSelectorController: PortfolioSelectorController, ) : Model() { @@ -45,6 +48,7 @@ internal class AddAndManageModel @Inject constructor( } fun onAddTokensClick() { + analyticsEventHandler.send(PortfolioAnalyticsEvent.ButtonAddTokens()) modelScope.launch { val data = portfolioFetcher.data.first() val isSingleAccount = data.isSingleChoice(params.userWalletId) @@ -65,6 +69,7 @@ internal class AddAndManageModel @Inject constructor( } fun onOrganizeTokensClick() { + analyticsEventHandler.send(PortfolioAnalyticsEvent.ButtonOrganizeTokens()) params.onDismiss() params.onOrganizeTokensClick() } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 71220bf8a9..0e34ecf2d4 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -23,6 +23,7 @@ import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplySetShouldShowMainPromoUseCase +import com.tangem.feature.wallet.child.managetokens.analytics.PortfolioAnalyticsEvent import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.analytics.utils.TokenListAnalyticsSender import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory @@ -123,6 +124,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( override fun onOrganizeTokensClick() { val userWalletId = stateHolder.getSelectedWalletId() if (walletFeatureToggles.isAddAndManageTokensEnabled) { + analyticsEventHandler.send(PortfolioAnalyticsEvent.ButtonAddManage()) router.openAddAndManageBottomSheet(userWalletId = userWalletId) } else { router.openOrganizeTokensScreen(userWalletId = userWalletId) diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModelTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModelTest.kt new file mode 100644 index 0000000000..73e1539274 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/managetokens/model/AddAndManageModelTest.kt @@ -0,0 +1,101 @@ +package com.tangem.feature.wallet.child.managetokens.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.models.account.AccountId +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.wallet.child.managetokens.AddAndManageBottomSheetComponent +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher +import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class AddAndManageModelTest { + + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val portfolioFetcher: PortfolioFetcher = mockk(relaxed = true) { + every { data } returns flowOf( + PortfolioFetcher.Data( + appCurrency = mockk(relaxed = true), + isBalanceHidden = false, + balances = emptyMap(), + ), + ) + } + private val portfolioFetcherFactory: PortfolioFetcher.Factory = mockk(relaxed = true) { + every { create(any(), any()) } returns portfolioFetcher + } + private val portfolioSelectorController: PortfolioSelectorController = mockk(relaxed = true) { + every { selectedAccount } returns flowOf(null) + } + + private val onDismiss: () -> Unit = mockk(relaxed = true) + private val onOrganizeTokensClick: () -> Unit = mockk(relaxed = true) + private val onManageTokensClick: (AccountId) -> Unit = mockk(relaxed = true) + + private val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF") + + private val params = AddAndManageBottomSheetComponent.Params( + userWalletId = userWalletId, + onDismiss = onDismiss, + onOrganizeTokensClick = onOrganizeTokensClick, + onManageTokensClick = onManageTokensClick, + ) + + private fun createModel(): AddAndManageModel = AddAndManageModel( + paramsContainer = MutableParamsContainer(params), + dispatchers = TestingCoroutineDispatcherProvider(), + portfolioFetcherFactory = portfolioFetcherFactory, + analyticsEventHandler = analyticsEventHandler, + portfolioSelectorController = portfolioSelectorController, + ) + + @Test + fun `GIVEN bottom sheet model WHEN onAddTokensClick THEN sends ButtonAddTokens event with correct payload`() = + runTest { + val model = createModel() + val captured = slot() + + model.onAddTokensClick() + + verify(exactly = 1) { analyticsEventHandler.send(capture(captured)) } + assertThat(captured.captured.category).isEqualTo("Portfolio") + assertThat(captured.captured.event).isEqualTo("Button - Add tokens") + assertThat(captured.captured.params).isEmpty() + } + + @Test + fun `GIVEN bottom sheet model WHEN onOrganizeTokensClick THEN sends ButtonOrganizeTokens event with correct payload`() = + runTest { + val model = createModel() + val captured = slot() + + model.onOrganizeTokensClick() + + verify(exactly = 1) { analyticsEventHandler.send(capture(captured)) } + assertThat(captured.captured.category).isEqualTo("Portfolio") + assertThat(captured.captured.event).isEqualTo("Button - Organize Tokens") + assertThat(captured.captured.params).isEmpty() + } + + @Test + fun `GIVEN bottom sheet model WHEN onOrganizeTokensClick THEN dismisses bottom sheet and forwards to params callback`() = + runTest { + val model = createModel() + + model.onOrganizeTokensClick() + + verify(exactly = 1) { onDismiss() } + verify(exactly = 1) { onOrganizeTokensClick() } + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntentsAnalyticsTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntentsAnalyticsTest.kt new file mode 100644 index 0000000000..d6a468a205 --- /dev/null +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntentsAnalyticsTest.kt @@ -0,0 +1,83 @@ +package com.tangem.feature.wallet.child.wallet.model.intents + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.feature.wallet.presentation.router.InnerWalletRouter +import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController +import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class WalletContentClickIntentsAnalyticsTest { + + private val stateHolder: WalletStateController = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val walletFeatureToggles: WalletFeatureToggles = mockk(relaxed = true) + private val router: InnerWalletRouter = mockk(relaxed = true) + + private val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF") + + private fun createImplementor(): WalletContentClickIntentsImplementor { + every { stateHolder.getSelectedWalletId() } returns userWalletId + + val implementor = WalletContentClickIntentsImplementor( + stateHolder = stateHolder, + currencyActionsClickIntents = mockk(relaxed = true), + onrampStatusFactory = mockk(relaxed = true), + getUserWalletUseCase = mockk(relaxed = true), + singleAccountStatusListSupplier = mockk(relaxed = true), + getCryptoCurrencyActionsUseCase = mockk(relaxed = true), + getExplorerTransactionUrlUseCase = mockk(relaxed = true), + shouldShowMarketsTooltipUseCase = mockk(relaxed = true), + dispatchers = mockk(relaxed = true), + walletEventSender = mockk(relaxed = true), + analyticsEventHandler = analyticsEventHandler, + accountDependencies = mockk(relaxed = true), + yieldSupplySetShouldShowMainPromoUseCase = mockk(relaxed = true), + tokenListAnalyticsSender = mockk(relaxed = true), + uiMessageSender = mockk(relaxed = true), + walletFeatureToggles = walletFeatureToggles, + ) + implementor.initialize(router = router, coroutineScope = TestScope()) + return implementor + } + + @Test + fun `GIVEN add and manage toggle enabled WHEN onOrganizeTokensClick THEN sends ButtonAddManage event and opens bottom sheet`() = + runTest { + every { walletFeatureToggles.isAddAndManageTokensEnabled } returns true + val implementor = createImplementor() + val captured = slot() + + implementor.onOrganizeTokensClick() + + verify(exactly = 1) { analyticsEventHandler.send(capture(captured)) } + assertThat(captured.captured.category).isEqualTo("Portfolio") + assertThat(captured.captured.event).isEqualTo("Button - Add Manage") + assertThat(captured.captured.params).isEmpty() + verify(exactly = 1) { router.openAddAndManageBottomSheet(userWalletId = userWalletId) } + verify(exactly = 0) { router.openOrganizeTokensScreen(any()) } + } + + @Test + fun `GIVEN add and manage toggle disabled WHEN onOrganizeTokensClick THEN does not send analytics and opens organize screen`() = + runTest { + every { walletFeatureToggles.isAddAndManageTokensEnabled } returns false + val implementor = createImplementor() + + implementor.onOrganizeTokensClick() + + verify(exactly = 0) { analyticsEventHandler.send(any()) } + verify(exactly = 1) { router.openOrganizeTokensScreen(userWalletId = userWalletId) } + verify(exactly = 0) { router.openAddAndManageBottomSheet(any()) } + } +} \ No newline at end of file From 08514bf0204529f5f0c12b8fa88d8e114bb462f7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 6 May 2026 18:36:29 +0500 Subject: [PATCH 168/206] Updated on 2026-08-14 --- .../tangempay/TangemPayAnalyticsEvents.kt | 25 +++++++++++++++++++ .../tangempay/model/TangemPayCardPageModel.kt | 1 + .../tangempay/model/TangemPayDetailsModel.kt | 9 ++++++- .../utils/TangemPayMessagesFactory.kt | 7 ++++-- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index 03db2b8e49..a8229e803a 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -217,4 +217,29 @@ sealed class TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa Permanent Button Clicked", ) + + class CardIconClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Card Icon Clicked", + ) + + class CardManagementScreenOpened : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Card Management Screen Opened", + ) + + class AddExtraCardClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Add Extra Card Clicked", + ) + + class FakeDoorPopupDisplayed : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Fakedoor Popup Displayed", + ) + + class FakeDoorGotitClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Fakedoor Gotit Clicked", + ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 45a98d4156..735d0b9398 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -82,6 +82,7 @@ internal class TangemPayCardPageModel @Inject constructor( // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed init { + analytics.send(TangemPayAnalyticsEvents.CardManagementScreenOpened()) fetchAddToWalletBanner() paymentAccountStatusSupplier.invoke(params.userWalletId) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 355908fcb0..a500999cdc 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -322,11 +322,18 @@ internal class TangemPayDetailsModel @Inject constructor( } override fun onCardClick() { + analytics.send(TangemPayAnalyticsEvents.CardIconClicked()) router.push(TangemPayAccountDetailsInnerRoute.CardDetails) } override fun onAddCardClick() { - uiMessageSender.send(message = TangemPayMessagesFactory.createFutureFeature()) + analytics.send(TangemPayAnalyticsEvents.AddExtraCardClicked()) + analytics.send(TangemPayAnalyticsEvents.FakeDoorPopupDisplayed()) + uiMessageSender.send( + message = TangemPayMessagesFactory.createFutureFeature( + onGotItClick = { analytics.send(TangemPayAnalyticsEvents.FakeDoorGotitClicked()) }, + ), + ) } private fun showBottomSheetError(type: TangemPayDetailsErrorType) { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt index e10ee99a4e..ad15a49737 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/TangemPayMessagesFactory.kt @@ -114,7 +114,7 @@ internal object TangemPayMessagesFactory { } } - fun createFutureFeature(): BottomSheetMessage { + fun createFutureFeature(onGotItClick: () -> Unit): BottomSheetMessage { return bottomSheetMessage { infoBlock { icon(R.drawable.ic_credit_card_add_24) { @@ -125,7 +125,10 @@ internal object TangemPayMessagesFactory { } primaryButton { text = resourceReference(R.string.common_got_it) - onClick { closeBs() } + onClick { + onGotItClick() + closeBs() + } } } } From 9f7f0b519f8e062aa309764546e3127eede54373 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 6 May 2026 16:41:08 +0300 Subject: [PATCH 169/206] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 1 + .../com/tangem/scenarios/AddressScenarios.kt | 5 --- .../scenarios/CheckMainScreenScenarios.kt | 8 ++-- .../tangem/scenarios/MainScreenScenarios.kt | 22 ++++++++++ .../AddAndManageBottomSheetPageObject.kt | 31 +++++++++++++ .../tangem/screens/MainScreenPageObject.kt | 17 ++++---- .../com/tangem/tests/OrganizeTokensTest.kt | 43 +++++-------------- .../kotlin/com/tangem/tests/StakingTest.kt | 12 +++--- .../com/tangem/tests/main/MainScreenTest.kt | 16 +++---- .../tangem/core/ui/test/MainScreenTestTags.kt | 1 + .../MultiCurrencyOrganizeButton.kt | 8 +++- 11 files changed, 100 insertions(+), 64 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/MainScreenScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/AddAndManageBottomSheetPageObject.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 3b22023f6e..b340c7b317 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -188,6 +188,7 @@ abstract class BaseTestCase : TestCase( "ACCOUNTS_FEATURE_ENABLED" to true, "GASLESS_APPROVAL_ENABLED" to true, "MAIN_SCREEN_QR_SCANNING_ENABLED" to true, + "ADD_AND_MANAGE_TOKENS_ENABLED" to true, ) ) } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt index f259dd60c4..e4d65afb99 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/AddressScenarios.kt @@ -30,11 +30,6 @@ fun BaseTestCase.verifyAddresses(seedPhrase: String, apiAddressesJson: String) { runCatching { onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } }.isSuccess } } - step("Assert 'Organize tokens' button is enabled") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { - runCatching { onMainScreen { organizeTokensButton().assertIsEnabled() } }.isSuccess - } - } step("Wait for all wallet managers to initialize") { awaitWalletManagersStabilized() } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt index 82c7f1feab..b8baf03580 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt @@ -72,8 +72,8 @@ fun BaseTestCase.checkSingleCurrencyMainScreen( onMainScreen { emptyTransactionBlockExploreButton.assertIsDisplayed() } } } - step("Assert 'Organize tokens' button is not displayed") { - onMainScreen { organizeTokensButtonWithoutLazySearch.assertIsNotDisplayed() } + step("Assert 'Add & Manage' button is not displayed") { + onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() } } } @@ -112,8 +112,8 @@ fun BaseTestCase.checkMultiCurrencyMainScreen( step("Assert 'Receive' button is not displayed") { onMainScreen { receiveButton.assertIsNotDisplayed() } } - step("Assert 'Organize tokens' button is displayed") { - onMainScreen { organizeTokensButton().assertIsDisplayed() } + step("Assert 'Add & Manage' button is displayed") { + onMainScreen { addAndManageButton().assertIsDisplayed() } } } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MainScreenScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MainScreenScenarios.kt new file mode 100644 index 0000000000..9c9b025c84 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MainScreenScenarios.kt @@ -0,0 +1,22 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.SwipeDirection +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.swipeVertical +import com.tangem.screens.onAddAndManageBottomSheet +import com.tangem.screens.onMainScreen +import io.qameta.allure.kotlin.Allure.step + +fun BaseTestCase.openOrganizeTokensScreen() { + step("Swipe to 'Add & Manage' button") { + swipeVertical(SwipeDirection.UP) + swipeVertical(SwipeDirection.UP) + } + step("Click on 'Add & Manage' button") { + onMainScreen { addAndManageButton().clickWithAssertion() } + } + step("Click on 'Organize tokens' button in bottom sheet") { + onAddAndManageBottomSheet { organizeTokensButton.clickWithAssertion() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddAndManageBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddAndManageBottomSheetPageObject.kt new file mode 100644 index 0000000000..38efc65897 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddAndManageBottomSheetPageObject.kt @@ -0,0 +1,31 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import io.github.kakaocup.compose.node.element.ComposeScreen +import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen +import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.kakao.common.utilities.getResourceString +import com.tangem.core.res.R as CoreResR + +class AddAndManageBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val title: KNode = child { + hasText(getResourceString(CoreResR.string.main_add_and_manage_tokens)) + useUnmergedTree = true + } + + val addTokensButton: KNode = child { + hasText(getResourceString(CoreResR.string.add_and_manage_sheet_manage_title)) + useUnmergedTree = true + } + + val organizeTokensButton: KNode = child { + hasText(getResourceString(CoreResR.string.add_and_manage_sheet_organize_title)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onAddAndManageBottomSheet(function: AddAndManageBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index d281e57344..82defdf7f0 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -19,6 +19,7 @@ import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText +import com.tangem.core.res.R as CoreResR import com.tangem.core.ui.R as CoreUiR class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : @@ -214,8 +215,8 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) hasText(getResourceString(CoreUiR.string.wallet_notification_address_copied)) } - val organizeTokensButtonNode: KNode = child { - hasTestTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON) + val addAndManageButtonNode: KNode = child { + hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON) useUnmergedTree = true } @@ -265,18 +266,18 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) } @OptIn(ExperimentalTestApi::class) - fun organizeTokensButton(): KNode { + fun addAndManageButton(): KNode { return lazyList.childWith { - hasTestTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON) + hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON) }.child { - hasText(getResourceString(R.string.organize_tokens_title)) + hasText(getResourceString(CoreResR.string.main_add_and_manage_tokens)) useUnmergedTree = true } } - val organizeTokensButtonWithoutLazySearch: KNode = child { - hasTestTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON) - hasText(getResourceString(R.string.organize_tokens_title)) + val addAndManageButtonWithoutLazySearch: KNode = child { + hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON) + hasText(getResourceString(CoreResR.string.main_add_and_manage_tokens)) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt index fb132f5e1d..81fe762f61 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/OrganizeTokensTest.kt @@ -2,10 +2,9 @@ package com.tangem.tests import androidx.compose.ui.test.onAllNodesWithText import com.tangem.common.BaseTestCase -import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeVertical import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.openOrganizeTokensScreen import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.onMainScreen import com.tangem.screens.onOrganizeTokensScreen @@ -31,12 +30,8 @@ class OrganizeTokensTest : BaseTestCase() { step("Click on 'Synchronize addresses' button") { onMainScreen { synchronizeAddressesButton.clickWithAssertion() } } - step("Swipe to 'Organize tokens' button") { - swipeVertical(SwipeDirection.UP) - swipeVertical(SwipeDirection.UP) - } - step("Click 'Organize tokens' button") { - onMainScreen { organizeTokensButton().clickWithAssertion() } + step("Open 'Organize tokens' screen") { + openOrganizeTokensScreen() } step("Assert 'Organize tokens' screen is opened") { onOrganizeTokensScreen { @@ -56,12 +51,8 @@ class OrganizeTokensTest : BaseTestCase() { step("Assert tokens were grouped on 'Main screen'") { onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } } - step("Swipe to 'Organize tokens' button") { - swipeVertical(SwipeDirection.UP) - swipeVertical(SwipeDirection.UP) - } - step("Click 'Organize tokens' button") { - onMainScreen { organizeTokensButton().clickWithAssertion() } + step("Open 'Organize tokens' screen") { + openOrganizeTokensScreen() } step("Assert 'Organize tokens' screen is opened") { onOrganizeTokensScreen { @@ -104,12 +95,8 @@ class OrganizeTokensTest : BaseTestCase() { tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed() } } - step("Swipe to 'Organize tokens' button") { - swipeVertical(SwipeDirection.UP) - swipeVertical(SwipeDirection.UP) - } - step("Click 'Organize tokens' button") { - onMainScreen { organizeTokensButton().clickWithAssertion() } + step("Open 'Organize tokens' screen") { + openOrganizeTokensScreen() } step("Check positions of tokens on 'Organize tokens' screen") { onOrganizeTokensScreen { @@ -141,12 +128,8 @@ class OrganizeTokensTest : BaseTestCase() { tokenWithTitleAndPosition(ethereumTitle, 1).assertIsDisplayed() } } - step("Swipe to 'Organize tokens' button") { - swipeVertical(SwipeDirection.UP) - swipeVertical(SwipeDirection.UP) - } - step("Click 'Organize tokens' button") { - onMainScreen { organizeTokensButton().clickWithAssertion() } + step("Open 'Organize tokens' screen") { + openOrganizeTokensScreen() } step("Drag $bitcoinTitle down on 'Organize tokens' screen") { composeTestRule.waitUntil(timeoutMillis = 100_000) { @@ -191,12 +174,8 @@ class OrganizeTokensTest : BaseTestCase() { tokenWithTitleAndPosition(polygonTitle, 2).assertIsDisplayed() } } - step("Swipe to 'Organize tokens' button") { - swipeVertical(SwipeDirection.UP) - swipeVertical(SwipeDirection.UP) - } - step("Click 'Organize tokens' button") { - onMainScreen { organizeTokensButton().clickWithAssertion() } + step("Open 'Organize tokens' screen") { + openOrganizeTokensScreen() } step("Check positions of tokens on 'Organize tokens' screen") { onOrganizeTokensScreen { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt index f91fe7df1d..d02ce135ee 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/StakingTest.kt @@ -40,8 +40,8 @@ class StakingTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Assert 'Organize tokens' button is displayed") { - onMainScreen { organizeTokensButton().assertIsDisplayed() } + step("Assert 'Add & Manage' button is displayed") { + onMainScreen { addAndManageButton().assertIsDisplayed() } } step("Swipe up") { swipeVertical(SwipeDirection.UP) @@ -98,8 +98,8 @@ class StakingTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Assert 'Organize tokens' button is displayed") { - onMainScreen { organizeTokensButton().assertIsDisplayed() } + step("Assert 'Add & Manage' button is displayed") { + onMainScreen { addAndManageButton().assertIsDisplayed() } } step("Swipe up") { swipeVertical(SwipeDirection.UP) @@ -156,8 +156,8 @@ class StakingTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Assert 'Organize tokens' button is displayed") { - onMainScreen { organizeTokensButton().assertIsDisplayed() } + step("Assert 'Add & Manage' button is displayed") { + onMainScreen { addAndManageButton().assertIsDisplayed() } } step("Swipe up") { swipeVertical(SwipeDirection.UP) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt index 172bfbf08f..ac99d2e42e 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/main/MainScreenTest.kt @@ -28,8 +28,8 @@ class MainScreenTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Assert 'Organize tokens' button is displayed") { - onMainScreen { organizeTokensButton().assertIsDisplayed() } + step("Assert 'Add & Manage' button is displayed") { + onMainScreen { addAndManageButton().assertIsDisplayed() } } } } @@ -56,8 +56,8 @@ class MainScreenTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Assert 'Organize tokens' button is not displayed") { - onMainScreen { organizeTokensButtonNode.assertIsNotDisplayed()} + step("Assert 'Add & Manage' button is not displayed") { + onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()} } } } @@ -81,8 +81,8 @@ class MainScreenTest : BaseTestCase() { step("Open 'Main Screen'") { openMainScreen() } - step("Assert 'Organize tokens' button is not displayed") { - onMainScreen { organizeTokensButtonNode.assertIsNotDisplayed()} + step("Assert 'Add & Manage' button is not displayed") { + onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()} } } } @@ -106,8 +106,8 @@ class MainScreenTest : BaseTestCase() { step("Open 'Main Screen'") { openMainScreen() } - step("Assert 'Organize tokens' button is not displayed") { - onMainScreen { organizeTokensButtonNode.assertIsDisplayed()} + step("Assert 'Add & Manage' button is displayed") { + onMainScreen { addAndManageButtonNode.assertIsDisplayed()} } } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt index 269fc77316..b8d5867f2e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/MainScreenTestTags.kt @@ -7,6 +7,7 @@ object MainScreenTestTags { const val TOKEN_LIST_ITEM = "MAIN_SCREEN_TOKEN_LIST_ITEM" const val WALLET_LIST_ITEM = "MAIN_SCREEN_WALLET_LIST_ITEM" const val ORGANIZE_TOKENS_BUTTON = "MAIN_SCREEN_ORGANIZE_TOKENS_BUTTON" + const val ADD_AND_MANAGE_BUTTON = "MAIN_SCREEN_ADD_AND_MANAGE_BUTTON" const val CARD_TITLE = "MAIN_SCREEN_CARD_TITLE" const val CARD_IMAGE = "MAIN_SCREEN_CARD_IMAGE" const val DEVICES_COUNT = "MAIN_SCREEN_DEVICES_COUNT" diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt index 6e5177817c..e1e2ba0ce6 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/components/multicurrency/MultiCurrencyOrganizeButton.kt @@ -7,6 +7,7 @@ import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.buttons.actions.RoundedActionButton import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.test.MainScreenTestTags +import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState private const val ORGANIZE_BUTTON_CONTENT_TYPE = "OrganizeTokensButton" @@ -24,8 +25,13 @@ internal fun LazyListScope.organizeTokensButton( modifier: Modifier = Modifier, ) { item(key = ORGANIZE_BUTTON_CONTENT_TYPE, contentType = ORGANIZE_BUTTON_CONTENT_TYPE) { + val testTag = if (config.textRes == R.string.main_add_and_manage_tokens) { + MainScreenTestTags.ADD_AND_MANAGE_BUTTON + } else { + MainScreenTestTags.ORGANIZE_TOKENS_BUTTON + } RoundedActionButton( - modifier = modifier.testTag(MainScreenTestTags.ORGANIZE_TOKENS_BUTTON), + modifier = modifier.testTag(testTag), config = ActionButtonConfig( text = resourceReference(id = config.textRes), iconResId = config.iconRes, From 3b1e6421502423e35e68cf602a5c108c4033ed9f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 6 May 2026 16:25:41 +0200 Subject: [PATCH 170/206] Updated on 2026-08-14 --- .../entry/components/FeedEntryComponent.kt | 1 + .../components/DefaultFeedEntryComponent.kt | 3 + .../tangem/features/feed/ui/EntryContent.kt | 61 +++++++++++++++---- .../wallet/child/wallet/WalletComponent.kt | 8 ++- .../presentation/wallet/ui/WalletScreen.kt | 12 ++-- .../presentation/wallet/ui/WalletScreen2.kt | 12 ++-- 6 files changed, 73 insertions(+), 24 deletions(-) diff --git a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt index f609996618..c99eeb7c18 100644 --- a/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt +++ b/features/feed/api/src/main/kotlin/com/tangem/features/feed/entry/components/FeedEntryComponent.kt @@ -17,6 +17,7 @@ interface FeedEntryComponent : ComposableContentComponent { fun BottomSheetContent( bottomSheetState: State, onHeaderSizeChange: (Dp) -> Unit, + onExpandSheet: () -> Unit, modifier: Modifier, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 94c272503e..ed639b8931 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -172,6 +172,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( override fun BottomSheetContent( bottomSheetState: State, onHeaderSizeChange: (Dp) -> Unit, + onExpandSheet: () -> Unit, modifier: Modifier, ) { val bsState by bottomSheetState @@ -191,6 +192,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( bottomSheetState = bottomSheetState, stackState = stackStack, onHeaderSizeChange = onHeaderSizeChange, + onExpandSheet = onExpandSheet, isOpenedInBottomSheet = true, ) } @@ -213,6 +215,7 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( bottomSheetState = bottomSheetState, stackState = stack.subscribeAsState(), onHeaderSizeChange = {}, + onExpandSheet = {}, isOpenedInBottomSheet = false, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt index d0bf1b73b9..93ab10f9bf 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/EntryContent.kt @@ -1,6 +1,7 @@ package com.tangem.features.feed.ui import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -9,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.arkivanov.decompose.ExperimentalDecomposeApi @@ -33,6 +35,7 @@ internal fun EntryContent( bottomSheetState: State, stackState: State>, onHeaderSizeChange: (Dp) -> Unit, + onExpandSheet: () -> Unit, isOpenedInBottomSheet: Boolean, ) { if (LocalRedesignEnabled.current) { @@ -40,6 +43,7 @@ internal fun EntryContent( bottomSheetState = bottomSheetState, stackState = stackState, onHeaderSizeChange = onHeaderSizeChange, + onExpandSheet = onExpandSheet, isOpenedInBottomSheet = isOpenedInBottomSheet, ) } else { @@ -47,6 +51,7 @@ internal fun EntryContent( bottomSheetState = bottomSheetState, stackState = stackState, onHeaderSizeChange = onHeaderSizeChange, + onExpandSheet = onExpandSheet, isOpenedInBottomSheet = isOpenedInBottomSheet, ) } @@ -57,6 +62,7 @@ private fun EntryContentV1( bottomSheetState: State, stackState: State>, onHeaderSizeChange: (Dp) -> Unit, + onExpandSheet: () -> Unit, isOpenedInBottomSheet: Boolean, ) { val density = LocalDensity.current @@ -68,7 +74,7 @@ private fun EntryContentV1( containerColor = background, contentWindowInsets = WindowInsetsZero, topBar = { - Children( + Box( modifier = Modifier .then( if (!isOpenedInBottomSheet) { @@ -84,10 +90,17 @@ private fun EntryContentV1( } } }, - stack = stackState.value, - animation = stackAnimation, - ) { child -> - child.instance.Title(bottomSheetState) + ) { + Children( + stack = stackState.value, + animation = stackAnimation, + ) { child -> + child.instance.Title(bottomSheetState) + } + CollapsedTitleClickOverlay( + bottomSheetState = bottomSheetState, + onExpandSheet = onExpandSheet, + ) } }, content = { contentPadding -> @@ -111,6 +124,7 @@ private fun EntryContentV2( bottomSheetState: State, stackState: State>, onHeaderSizeChange: (Dp) -> Unit, + onExpandSheet: () -> Unit, isOpenedInBottomSheet: Boolean, ) { val density = LocalDensity.current @@ -142,7 +156,7 @@ private fun EntryContentV2( bottomSheetState = bottomSheetState, ) } - AnimatedContent( + Box( modifier = Modifier .align(Alignment.TopStart) .then( @@ -161,14 +175,37 @@ private fun EntryContentV2( } } }, - targetState = stackState.value.active, - transitionSpec = animationAppBar, - contentKey = { it.key }, - label = "FeedEntryAppBar", - ) { state -> - state.instance.Title(bottomSheetState) + ) { + AnimatedContent( + targetState = stackState.value.active, + transitionSpec = animationAppBar, + contentKey = { it.key }, + label = "FeedEntryAppBar", + ) { state -> + state.instance.Title(bottomSheetState) + } + CollapsedTitleClickOverlay( + bottomSheetState = bottomSheetState, + onExpandSheet = onExpandSheet, + ) } } } } +} + +@Composable +private fun BoxScope.CollapsedTitleClickOverlay(bottomSheetState: State, onExpandSheet: () -> Unit) { + if (bottomSheetState.value == BottomSheetState.COLLAPSED) { + Box( + modifier = Modifier + .matchParentSize() + .clickable( + interactionSource = null, + indication = null, + onClick = onExpandSheet, + ) + .clearAndSetSemantics {}, + ) + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt index d6f6a2e7bc..cc0155e82f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/WalletComponent.kt @@ -260,10 +260,11 @@ internal class WalletComponent @AssistedInject constructor( WalletScreen2( state = uiState, tangemPayComponent = tangemPayMainBlockComponent, - bottomSheetContent = { + bottomSheetContent = { onExpandSheet -> BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = { headerSize = it }, + onExpandSheet = onExpandSheet, modifier = modifier, ) }, @@ -275,10 +276,11 @@ internal class WalletComponent @AssistedInject constructor( state = uiState, promoBannersBlockComponent = promoBannersBlockComponent, tangemPayComponent = tangemPayMainBlockComponent, - bottomSheetContent = { + bottomSheetContent = { onExpandSheet -> BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = { headerSize = it }, + onExpandSheet = onExpandSheet, modifier = modifier, ) }, @@ -305,11 +307,13 @@ internal class WalletComponent @AssistedInject constructor( private fun BottomSheetContent( bottomSheetState: State, onHeaderSizeChange: (Dp) -> Unit, + onExpandSheet: () -> Unit, modifier: Modifier = Modifier, ) { feedEntryComponent.BottomSheetContent( bottomSheetState = bottomSheetState, onHeaderSizeChange = onHeaderSizeChange, + onExpandSheet = onExpandSheet, modifier = modifier, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt index 23de72403e..e033dbe5ac 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen.kt @@ -95,7 +95,7 @@ internal fun WalletScreen( state: WalletScreenState, tangemPayComponent: TangemPayMainBlockComponent, promoBannersBlockComponent: ComposableContentComponent? = null, - bottomSheetContent: @Composable (() -> Unit), + bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit, bottomSheetHeaderHeightProvider: () -> Dp, onBottomSheetStateChange: (BottomSheetState) -> Unit, ) { @@ -139,7 +139,7 @@ private fun WalletContent( promoBannersBlockComponent: ComposableContentComponent? = null, bottomSheetHeaderHeightProvider: () -> Dp, onBottomSheetStateChange: (BottomSheetState) -> Unit, - bottomSheetContent: @Composable (() -> Unit), + bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit, ) { /* * Don't pass key to remember, because it will brake scroll animation. @@ -295,7 +295,7 @@ private inline fun BaseScaffoldWithMarkets( snackbarHostState: SnackbarHostState, bottomSheetHeaderHeightProvider: () -> Dp, noinline onBottomSheetStateChange: (BottomSheetState) -> Unit, - crossinline bottomSheetContent: @Composable () -> Unit, + crossinline bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit, crossinline content: @Composable (PaddingValues) -> Unit, ) { val isKeyboardVisible by rememberIsKeyboardVisible() @@ -382,7 +382,7 @@ private inline fun BaseScaffoldWithMarkets( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() - // expand bottom sheet when clicked on the header + // expand bottom sheet when clicked on the drag handle .clickable( enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, indication = null, @@ -400,7 +400,9 @@ private inline fun BaseScaffoldWithMarkets( isSearchFieldFocused = it.isFocused }, ) { - bottomSheetContent() + bottomSheetContent { + coroutineScope.launch { bottomSheetState.expand() } + } } } }, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt index 376948e5ae..e9a58bd659 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/ui/WalletScreen2.kt @@ -86,7 +86,7 @@ internal fun WalletScreen2( state: WalletScreenState, tangemPayComponent: TangemPayMainBlockComponent, modifier: Modifier = Modifier, - bottomSheetContent: @Composable (() -> Unit), + bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit, bottomSheetHeaderHeightProvider: () -> Dp, onBottomSheetStateChange: (BottomSheetState) -> Unit, ) { @@ -162,7 +162,7 @@ private fun WalletContent2( modifier: Modifier = Modifier, bottomSheetHeaderHeightProvider: () -> Dp, onBottomSheetStateChange: (BottomSheetState) -> Unit, - bottomSheetContent: @Composable (() -> Unit), + bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit, ) { val density = LocalDensity.current val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } @@ -343,7 +343,7 @@ private inline fun BaseScaffoldWithMarkets( modifier: Modifier = Modifier, noinline onBottomSheetStateChange: (BottomSheetState) -> Unit, crossinline appBarContent: @Composable () -> Unit, - crossinline bottomSheetContent: @Composable () -> Unit, + crossinline bottomSheetContent: @Composable (onExpandSheet: () -> Unit) -> Unit, crossinline content: @Composable (PaddingValues, TangemSheetState) -> Unit, ) { val density = LocalDensity.current @@ -384,7 +384,9 @@ private inline fun BaseScaffoldWithMarkets( isSearchFieldFocused = focusState.isFocused }, ) { - bottomSheetContent() + bottomSheetContent { + coroutineScope.launch { bottomSheetState.expand() } + } } }, content = { paddingValues -> @@ -456,7 +458,7 @@ private fun BottomSheet( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - // expand bottom sheet when clicked on the header + // expand bottom sheet when clicked on the drag handle .clickable( enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded, indication = null, From d5af712b2bedb0246349ce0124243d41ff43b779 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 6 May 2026 20:41:16 +0500 Subject: [PATCH 171/206] Updated on 2026-08-14 --- .../com/tangem/feature/swap/ui/TransactionCard.kt | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index d511cdb1ce..65aad222b7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -225,8 +225,7 @@ private fun TransactionCardLoading(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, + Column( modifier = Modifier.fillMaxWidth(), ) { TextShimmer( @@ -278,7 +277,7 @@ private fun TransactionCardLoading(modifier: Modifier = Modifier) { @Composable private fun Header(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) { - Row( + Column( modifier = modifier .fillMaxWidth() .padding( @@ -288,8 +287,6 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie end = TangemTheme.dimens.spacing12, ) .testTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, ) { val titleColor = if (type.inputError is TransactionCardType.InputError.Empty) { TangemTheme.colors.text.tertiary @@ -310,9 +307,7 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie text = balanceText, color = TangemTheme.colors.text.tertiary, style = TangemTheme.typography.body2, - modifier = Modifier - .align(Alignment.CenterVertically) - .testTag(SwapTokenScreenTestTags.BALANCE), + modifier = Modifier.testTag(SwapTokenScreenTestTags.BALANCE), ) } } else { From 27820ec8adea6f7569b473a460276779a3191890 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 6 May 2026 22:27:57 +0500 Subject: [PATCH 172/206] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 1 + .../tangem/data/pay/di/TangemPayDataModule.kt | 9 ++ .../pay/flow/PaymentAccountStatusFetcher.kt | 6 + .../usecase/SetTangemPayCardLimitUseCase.kt | 5 +- .../usecase/UpdateTangemPayCardNameUseCase.kt | 22 +++ ...faultTangemPayDetailsContainerComponent.kt | 4 + .../TangemPayAddToWalletComponent.kt | 2 +- .../TangemPayCardPageScreenComponent.kt | 2 +- .../TangemPayEditDisplayNameComponent.kt | 4 +- .../TangemPayCardDetailsBlockComponent.kt | 2 +- .../TangemPayCardDetailsBlockStateFactory.kt | 39 +++-- .../entity/TangemPayDetailsStateFactory.kt | 1 + .../tangempay/entity/TangemPayDetailsUM.kt | 14 +- .../entity/TangemPayEditDisplayNameUM.kt | 6 +- .../model/TangemPayCardDetailsBlockModel.kt | 39 +++-- .../tangempay/model/TangemPayDetailsModel.kt | 52 ++++++- .../model/TangemPayEditDisplayNameModel.kt | 60 ++++++-- .../DetailsAddToWalletBannerTransformer.kt | 22 +++ ...ngemPayCardDetailsUpdateNameTransformer.kt | 15 ++ .../TangemPayAccountDetailsInnerRoute.kt | 3 + .../tangempay/ui/TangemPayCardDetailsBlock.kt | 139 +++++++++--------- .../tangempay/ui/TangemPayCardPageScreen.kt | 31 ++-- .../tangempay/ui/TangemPayDetailsScreen.kt | 18 ++- .../ui/TangemPayEditDisplayNameScreen.kt | 2 +- 24 files changed, 356 insertions(+), 142 deletions(-) create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/UpdateTangemPayCardNameUseCase.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDetailsUpdateNameTransformer.kt diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 9e3feaa6e5..e20ccf50f8 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1690,6 +1690,7 @@ Withdraw unavailable now You can\'t initiate swap or new withdrawal till the current one is finished Withdrawal in progress + Card name Set a limit from %s We couldn’t set the limit. Please try again Change diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 7f41dabc9c..ade825296a 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -29,6 +29,7 @@ import com.tangem.domain.pay.repository.* import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayWithdrawUseCase @@ -148,6 +149,14 @@ internal interface TangemPayDataModule { return SetTangemPayCardLimitUseCase(cardDetailsRepository, paymentAccountStatusFetcher) } + @Provides + fun provideUpdateTangemPayCardNameUseCase( + cardDetailsRepository: TangemPayCardDetailsRepository, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + ): UpdateTangemPayCardNameUseCase { + return UpdateTangemPayCardNameUseCase(cardDetailsRepository, paymentAccountStatusFetcher) + } + @Provides @Singleton fun provideGetTangemPayCryptoCurrencyStatusUseCase( diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/flow/PaymentAccountStatusFetcher.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/flow/PaymentAccountStatusFetcher.kt index 740d9d0824..eed0daaec2 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/flow/PaymentAccountStatusFetcher.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/flow/PaymentAccountStatusFetcher.kt @@ -1,8 +1,14 @@ package com.tangem.domain.pay.flow +import arrow.core.Either import com.tangem.domain.core.flow.FlowFetcher import com.tangem.domain.models.wallet.UserWalletId interface PaymentAccountStatusFetcher : FlowFetcher { + + suspend operator fun invoke(userWalletId: UserWalletId): Either { + return invoke(Params(userWalletId)) + } + data class Params(val userWalletId: UserWalletId) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt index 9c66f8d75e..b3fd1ebb56 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/SetTangemPayCardLimitUseCase.kt @@ -17,9 +17,6 @@ class SetTangemPayCardLimitUseCase( amount: BigDecimal, ): Either { return cardDetailsRepository.updateCardLimit(cardId, userWalletId, amount.toPlainString()) - .onRight { - val params = PaymentAccountStatusFetcher.Params(userWalletId) - paymentAccountStatusFetcher.invoke(params) - } + .onRight { paymentAccountStatusFetcher.invoke(userWalletId) } } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/UpdateTangemPayCardNameUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/UpdateTangemPayCardNameUseCase.kt new file mode 100644 index 0000000000..5020313889 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/UpdateTangemPayCardNameUseCase.kt @@ -0,0 +1,22 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.account.CardDisplayName +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository + +class UpdateTangemPayCardNameUseCase( + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, +) { + suspend operator fun invoke( + cardId: String, + userWalletId: UserWalletId, + displayName: CardDisplayName, + ): Either { + return cardDetailsRepository.updateCardDisplayName(cardId, userWalletId, displayName) + .onRight { paymentAccountStatusFetcher(userWalletId) } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 52b0069572..94de672f75 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -69,6 +69,10 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru context = childByContext(componentContext = componentContext, router = innerRouter), params = TangemPayCardPageComponent.Params(userWalletId = params.userWalletId, config = params.config), ) + TangemPayAccountDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( + appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), + params = params, + ) } private fun onChildBack() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt index 91d394396e..f04ea0b8de 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayAddToWalletComponent.kt @@ -25,7 +25,7 @@ internal class TangemPayAddToWalletComponent( appComponentContext = child("cardDetailsBlockComponent"), params = TangemPayCardDetailsBlockComponent.Params( params = params, - isDisplayCardNameEnabled = false, + isEditingNameEnabled = false, ), ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt index 9d3e29f799..87a347f661 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -39,7 +39,7 @@ internal class TangemPayCardPageScreenComponent( appComponentContext = child("cardDetailsBlockComponent"), params = TangemPayCardDetailsBlockComponent.Params( params = containerParams, - isDisplayCardNameEnabled = true, + isEditingNameEnabled = true, ), ) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt index 8b4bb6214d..571c294824 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt @@ -24,7 +24,7 @@ internal class TangemPayEditDisplayNameComponent( private val cardDetailsBlockComponent = DefaultTangemPayCardDetailsBlockComponent( appComponentContext = child("editDisplayNameCardDetails"), - params = TangemPayCardDetailsBlockComponent.Params(params = params, isDisplayCardNameEnabled = true), + params = TangemPayCardDetailsBlockComponent.Params(params = params, isEditingNameEnabled = false), ) @Composable @@ -33,7 +33,7 @@ internal class TangemPayEditDisplayNameComponent( val cardDetailsState by cardDetailsBlockComponent.state.collectAsStateWithLifecycle() val editingCardDetailsState = cardDetailsState.copy( displayNameState = DisplayNameState.Editing( - displayName = state.editingValue, + displayName = state.editingValue.text, editingValue = state.editingValue, onValueChanged = state.onValueChanged, onSubmit = state.onDoneClick, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt index 9b80de3eb5..c877c6cddb 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/cardDetails/TangemPayCardDetailsBlockComponent.kt @@ -16,6 +16,6 @@ internal interface TangemPayCardDetailsBlockComponent { data class Params( val params: TangemPayDetailsContainerComponent.Params, - val isDisplayCardNameEnabled: Boolean, + val isEditingNameEnabled: Boolean, ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt index d57030a177..698d35ec97 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayCardDetailsBlockStateFactory.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.entity import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.model.CardDataType @@ -8,21 +9,33 @@ import com.tangem.utils.StringsSigns internal class TangemPayCardDetailsBlockStateFactory( private val cardNumberEnd: String, - private val displayNameState: DisplayNameState?, + private val displayName: CardDisplayName?, + private val isEditingNameEnabled: Boolean, + private val onEditNameClick: () -> Unit, private val onReveal: () -> Unit, private val onCopy: (String, CardDataType) -> Unit, ) { - fun getInitialState() = TangemPayCardDetailsUM( - number = "", - numberShort = "${StringsSigns.ASTERISK}$cardNumberEnd", - expiry = "", - cvv = "", - buttonText = resourceReference(R.string.tangempay_card_details_reveal_text), - onClick = onReveal, - onCopy = onCopy, - isHidden = true, - cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = displayNameState, - ) + fun getInitialState(): TangemPayCardDetailsUM { + return TangemPayCardDetailsUM( + number = "", + numberShort = "${StringsSigns.ASTERISK}$cardNumberEnd", + expiry = "", + cvv = "", + buttonText = resourceReference(R.string.tangempay_card_details_reveal_text), + onClick = onReveal, + onCopy = onCopy, + isHidden = true, + cardFrozenState = TangemPayCardFrozenState.Unfrozen, + displayNameState = if (displayName != null) { + DisplayNameState.Display( + displayName = displayName.value, + onClick = onEditNameClick, + isEditingEnabled = isEditingNameEnabled, + ) + } else { + null + }, + ) + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index ea86449252..2f630968ac 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -59,6 +59,7 @@ internal class TangemPayDetailsStateFactory( ), isBalanceHidden = false, addFundsEnabled = true, + addToWalletBlockState = null, accountDeactivatedNotificationConfig = NotificationConfig( title = resourceReference(R.string.tangempay_account_deactivated_message_title), subtitle = resourceReference(R.string.tangempay_account_deactivated_message_subtitle), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index b6b591544f..50fb7a2683 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -1,5 +1,6 @@ package com.tangem.features.tangempay.entity +import androidx.compose.ui.text.input.TextFieldValue import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig import com.tangem.core.ui.components.notifications.NotificationConfig @@ -12,6 +13,7 @@ internal data class TangemPayDetailsUM( val topBarConfig: TangemPayDetailsTopBarConfig, val pullToRefreshConfig: PullToRefreshConfig, val balanceBlockState: TangemPayDetailsBalanceBlockState, + val addToWalletBlockState: AddToWalletBlockState?, val isBalanceHidden: Boolean, val addFundsEnabled: Boolean, val accountDeactivatedNotificationConfig: NotificationConfig?, @@ -39,15 +41,23 @@ internal sealed interface DisplayNameState { data class Display( override val displayName: String, val onClick: () -> Unit, + val isEditingEnabled: Boolean, ) : DisplayNameState data class Editing( override val displayName: String, - val editingValue: String, - val onValueChanged: (String) -> Unit, + val editingValue: TextFieldValue, + val onValueChanged: (TextFieldValue) -> Unit, val onSubmit: () -> Unit, val onDismiss: () -> Unit, ) : DisplayNameState + + fun copySealed(displayName: String): DisplayNameState { + return when (this) { + is Display -> copy(displayName = displayName) + is Editing -> copy(displayName = displayName) + } + } } internal sealed class TangemPayDetailsBalanceBlockState { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt index a13fdbc1d0..8d4c09a816 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt @@ -1,9 +1,11 @@ package com.tangem.features.tangempay.entity +import androidx.compose.ui.text.input.TextFieldValue + internal data class TangemPayEditDisplayNameUM( - val editingValue: String, + val editingValue: TextFieldValue, val isLoading: Boolean, - val onValueChanged: (String) -> Unit, + val onValueChanged: (TextFieldValue) -> Unit, val onDoneClick: () -> Unit, val onDismiss: () -> Unit, ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index 9bcf1fcdd1..4e97b58c7d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -10,11 +10,16 @@ import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.hasCardWithId +import com.tangem.domain.models.account.requireCardWithId +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsBlockStateFactory import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.model.listener.CardDetailsEvent @@ -22,10 +27,12 @@ import com.tangem.features.tangempay.model.listener.CardDetailsEventListener import com.tangem.features.tangempay.model.transformers.DetailsHiddenStateTransformer import com.tangem.features.tangempay.model.transformers.DetailsRevealProgressStateTransformer import com.tangem.features.tangempay.model.transformers.DetailsRevealedStateTransformer +import com.tangem.features.tangempay.model.transformers.TangemPayCardDetailsUpdateNameTransformer import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn +import com.tangem.utils.transformer.update import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -46,20 +53,16 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val cardDetailsEventListener: CardDetailsEventListener, private val analytics: AnalyticsEventHandler, private val router: Router, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, ) : Model() { private val params: TangemPayCardDetailsBlockComponent.Params = paramsContainer.require() private val stateFactory = TangemPayCardDetailsBlockStateFactory( cardNumberEnd = params.params.config.cardNumberEnd, - displayNameState = if (params.isDisplayCardNameEnabled && params.params.config.displayName != null) { - DisplayNameState.Display( - displayName = requireNotNull(params.params.config.displayName).value, - onClick = ::startEditingDisplayName, - ) - } else { - null - }, + displayName = params.params.config.displayName, + isEditingNameEnabled = params.isEditingNameEnabled, + onEditNameClick = ::startEditingDisplayName, onReveal = ::revealCardDetails, onCopy = ::copyData, ) @@ -71,6 +74,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val showCardDetailsTimerJobHolder = JobHolder() init { + subscribeToCardNameChanges(cardId = params.params.config.cardId, userWalletId = params.params.userWalletId) subscribeToCardFrozenState() modelScope.launch { cardDetailsEventListener.event.collectLatest { event -> @@ -82,6 +86,23 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } } + private fun subscribeToCardNameChanges(cardId: String, userWalletId: UserWalletId) { + paymentAccountStatusSupplier.invoke(userWalletId) + .onEach { state -> + val status = state.value + if (status is PaymentAccountStatusValue.Loaded && + status.source == StatusSource.ACTUAL && + status.hasCardWithId(cardId) + ) { + val card = status.requireCardWithId(cardId) + val displayName = card.displayName ?: return@onEach + + uiState.update(TangemPayCardDetailsUpdateNameTransformer(displayName)) + } + } + .launchIn(modelScope) + } + private fun subscribeToCardFrozenState() { cardDetailsRepository .cardFrozenState(params.params.config.cardId) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index a500999cdc..dded4437b7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -39,10 +39,7 @@ import com.tangem.features.tangempay.entity.TangemPayDetailsStateFactory import com.tangem.features.tangempay.entity.TangemPayDetailsUM import com.tangem.features.tangempay.model.listener.CardDetailsEvent import com.tangem.features.tangempay.model.listener.CardDetailsEventListener -import com.tangem.features.tangempay.model.transformers.DetailBalanceVisibilityTransformer -import com.tangem.features.tangempay.model.transformers.DetailsBalanceTransformer -import com.tangem.features.tangempay.model.transformers.TangemPayDetailsRefreshTransformer -import com.tangem.features.tangempay.model.transformers.TangemPayFreezeUnfreezeStateTransformer +import com.tangem.features.tangempay.model.transformers.* import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.utils.TangemPayDetailIntents import com.tangem.features.tangempay.utils.TangemPayMessagesFactory @@ -56,7 +53,10 @@ import com.tangem.utils.coroutines.saveIn import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject @@ -100,6 +100,7 @@ internal class TangemPayDetailsModel @Inject constructor( private val refreshStateJobHolder = JobHolder() private val fetchBalanceJobHolder = JobHolder() + private val addToWalletBannerJobHolder = JobHolder() private var balance: TangemPayCardBalance? = null @@ -116,6 +117,7 @@ internal class TangemPayDetailsModel @Inject constructor( fetchBalance() if (!params.config.isTangemPayDeactivated) { subscribeToCardFrozenState() + fetchAddToWalletBanner() } } @@ -231,6 +233,24 @@ internal class TangemPayDetailsModel @Inject constructor( }.saveIn(fetchBalanceJobHolder) } + private fun fetchAddToWalletBanner() { + modelScope.launch { + val isDone = try { + cardDetailsRepository.isAddToWalletDone(params.userWalletId).getOrNull() == true + } catch (e: Exception) { + TangemLogger.e("Error", e) + return@launch + } + uiState.update( + transformer = DetailsAddToWalletBannerTransformer( + onClickBanner = ::onClickAddToWalletBlock, + onClickCloseBanner = ::onClickCloseAddToWalletBlock, + isDone = isDone, + ), + ) + }.saveIn(addToWalletBannerJobHolder) + } + private fun handleBalanceHiding() { getBalanceHidingSettingsUseCase().onEach { uiState.update(DetailBalanceVisibilityTransformer(isHidden = it.isBalanceHidden)) @@ -260,6 +280,28 @@ internal class TangemPayDetailsModel @Inject constructor( }.saveIn(refreshStateJobHolder) } + private fun onClickAddToWalletBlock() { + analytics.send(TangemPayAnalyticsEvents.AddToWalletClicked()) + router.push(TangemPayAccountDetailsInnerRoute.AddToWallet) + } + + private fun onClickCloseAddToWalletBlock() { + modelScope.launch { + try { + cardDetailsRepository.setAddToWalletAsDone(params.userWalletId) + } catch (e: Exception) { + TangemLogger.e("Error", e) + } + uiState.update( + transformer = DetailsAddToWalletBannerTransformer( + onClickBanner = ::onClickAddToWalletBlock, + onClickCloseBanner = ::onClickCloseAddToWalletBlock, + isDone = true, + ), + ) + }.saveIn(addToWalletBannerJobHolder) + } + private fun onOpenMenu() { analytics.send(TangemPayAnalyticsEvents.CardSettingsClicked()) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt index f0ed2d6e98..352d5d1373 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -1,6 +1,8 @@ package com.tangem.features.tangempay.model import androidx.compose.runtime.Stable +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -8,15 +10,19 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.message.DialogMessage +import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.CardDisplayName -import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.hasCardWithId +import com.tangem.domain.models.account.requireCardWithId +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.TangemPayEditDisplayNameUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -26,8 +32,9 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, - private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val updateCardNameUseCase: UpdateTangemPayCardNameUseCase, private val uiMessageSender: UiMessageSender, + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, ) : Model() { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() @@ -37,7 +44,10 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( val uiState: StateFlow field = MutableStateFlow( TangemPayEditDisplayNameUM( - editingValue = originalDisplayName, + editingValue = TextFieldValue( + text = originalDisplayName, + selection = TextRange(originalDisplayName.length), + ), isLoading = false, onValueChanged = ::onValueChanged, onDoneClick = ::onDoneClick, @@ -45,23 +55,51 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( ), ) - private fun onValueChanged(value: String) { - if (value.length <= CardDisplayName.MAX_LENGTH) { + init { + subscribeToCardNameChanges(params.config.cardId, params.userWalletId) + } + + private fun subscribeToCardNameChanges(cardId: String, userWalletId: UserWalletId) { + paymentAccountStatusSupplier.invoke(userWalletId) + .onEach { state -> + val status = state.value + if (status is PaymentAccountStatusValue.Loaded && + status.source == StatusSource.ACTUAL && + status.hasCardWithId(cardId) + ) { + val card = status.requireCardWithId(cardId) + val displayName = card.displayName ?: return@onEach + + uiState.update { uiState -> + uiState.copy( + editingValue = TextFieldValue( + text = displayName.value, + selection = TextRange(displayName.value.length), + ), + ) + } + } + } + .launchIn(modelScope) + } + + private fun onValueChanged(value: TextFieldValue) { + if (value.text.length <= CardDisplayName.MAX_LENGTH) { uiState.update { it.copy(editingValue = value) } } } private fun onDoneClick() { - val currentValue = uiState.value.editingValue + val currentValue = uiState.value.editingValue.text if (currentValue.trim() == originalDisplayName.trim()) { router.pop() return } CardDisplayName(currentValue) .onRight { cardDisplayName -> - uiState.update { it.copy(isLoading = true) } modelScope.launch { - cardDetailsRepository.updateCardDisplayName( + uiState.update { it.copy(isLoading = true) } + updateCardNameUseCase( cardId = params.config.cardId, userWalletId = params.userWalletId, displayName = cardDisplayName, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt new file mode 100644 index 0000000000..3fe7bddbf8 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/DetailsAddToWalletBannerTransformer.kt @@ -0,0 +1,22 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.features.tangempay.entity.AddToWalletBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class DetailsAddToWalletBannerTransformer( + private val onClickBanner: () -> Unit, + private val onClickCloseBanner: () -> Unit, + private val isDone: Boolean, +) : Transformer { + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + return prevState.copy( + addToWalletBlockState = if (isDone) { + null + } else { + AddToWalletBlockState(onClick = onClickBanner, onClickClose = onClickCloseBanner) + }, + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDetailsUpdateNameTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDetailsUpdateNameTransformer.kt new file mode 100644 index 0000000000..9c7df001c2 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDetailsUpdateNameTransformer.kt @@ -0,0 +1,15 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.domain.models.account.CardDisplayName +import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM +import com.tangem.utils.transformer.Transformer + +internal class TangemPayCardDetailsUpdateNameTransformer( + private val displayName: CardDisplayName, +) : Transformer { + + override fun transform(prevState: TangemPayCardDetailsUM): TangemPayCardDetailsUM { + val displayNameState = prevState.displayNameState ?: return prevState + return prevState.copy(displayNameState = displayNameState.copySealed(displayName = displayName.value)) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt index 6bc9172607..8ef5c5e9a3 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt @@ -10,4 +10,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route { @Serializable data object CardDetails : TangemPayAccountDetailsInnerRoute() + + @Serializable + data object AddToWallet : TangemPayAccountDetailsInnerRoute() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index efe8c8a8dd..0a58c2672a 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -1,24 +1,22 @@ package com.tangem.features.tangempay.ui -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.EaseInOut -import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -46,6 +44,7 @@ import com.tangem.core.ui.components.SpacerWMax import com.tangem.core.ui.components.buttons.common.TangemButton import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition import com.tangem.core.ui.components.buttons.common.TangemButtonSize +import com.tangem.core.ui.extensions.conditional import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme @@ -58,7 +57,7 @@ import com.tangem.features.tangempay.entity.DisplayNameState import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM import com.tangem.features.tangempay.model.CardDataType -private const val ICON_FADE_DURATION_MS = 300 +private const val TEXT_WIDTH_PADDING = 2 private val CustomCardBlockColor = Color(0x1F828282) @Suppress("MagicNumber") @@ -228,80 +227,55 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif @Composable private fun CardDisplayName(state: DisplayNameState, modifier: Modifier = Modifier) { - val isDisplayMode = state is DisplayNameState.Display - - Row( - modifier = modifier.then( - if (state is DisplayNameState.Display) { - Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = state.onClick, - ) - } else { - Modifier - }, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - when (state) { - is DisplayNameState.Display -> DisplayOnlyCardDisplayName(state = state) - is DisplayNameState.Editing -> EditingCardDisplayName(state = state) - } - val iconVisibleState = remember { - MutableTransitionState(initialState = !isDisplayMode).apply { - targetState = isDisplayMode - } - } - AnimatedVisibility( - visibleState = iconVisibleState, - enter = fadeIn(animationSpec = tween(durationMillis = ICON_FADE_DURATION_MS)), - exit = fadeOut(animationSpec = tween(durationMillis = ICON_FADE_DURATION_MS)), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Modifier.width(6.dp)) - Icon( - painter = painterResource(id = com.tangem.core.ui.R.drawable.ic_edit_new_12), - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = TangemTheme.colors.text.constantWhite, - ) - } - } + when (state) { + is DisplayNameState.Display -> DisplayOnlyCardDisplayName(modifier = modifier, state = state) + is DisplayNameState.Editing -> EditingCardDisplayName(modifier = modifier, state = state) } } @Composable private fun DisplayOnlyCardDisplayName(state: DisplayNameState.Display, modifier: Modifier = Modifier) { - Text( - text = state.displayName, - style = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite), - maxLines = 1, - modifier = modifier, - ) + Row( + modifier = modifier.conditional( + condition = state.isEditingEnabled, + modifier = { clickable(onClick = state.onClick) }, + ), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.displayName, + style = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite), + maxLines = 1, + ) + if (state.isEditingEnabled) { + Icon( + painter = painterResource(id = R.drawable.ic_edit_new_12), + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = TangemTheme.colors.text.constantWhite, + ) + } + } } @Composable private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Modifier = Modifier) { val focusRequester = remember { FocusRequester() } - var textFieldValue by remember(state.editingValue) { - mutableStateOf( - TextFieldValue(text = state.editingValue, selection = TextRange(state.editingValue.length)), - ) - } + val placeholder = stringResourceSafe(R.string.tangempay_card_edit_name_placeholder) val textStyle = TangemTheme.typography.caption1.copy(color = TangemTheme.colors.text.constantWhite) val textMeasurer = rememberTextMeasurer() + val measuredText = state.editingValue.text.ifEmpty { placeholder } val textWidthDp = with(LocalDensity.current) { - textMeasurer.measure(textFieldValue.text, textStyle).size.width.toDp() + 2.dp + textMeasurer.measure(measuredText, textStyle).size.width.toDp() + TEXT_WIDTH_PADDING.dp } BasicTextField( - value = textFieldValue, + value = state.editingValue, onValueChange = { newValue -> - if (newValue.text.length in 0..CardDisplayName.MAX_LENGTH) { - textFieldValue = newValue - state.onValueChanged(newValue.text) + if (newValue.text.length <= CardDisplayName.MAX_LENGTH) { + state.onValueChanged(newValue) } }, modifier = modifier @@ -312,6 +286,17 @@ private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Mo cursorBrush = SolidColor(TangemTheme.colors.text.constantWhite), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { state.onSubmit() }), + decorationBox = { innerTextField -> + Box { + if (state.editingValue.text.isEmpty()) { + Text( + text = placeholder, + style = textStyle.copy(color = TangemTheme.colors.text.tertiary), + ) + } + innerTextField() + } + }, ) LaunchedEffect(Unit) { focusRequester.requestFocus() } @@ -492,7 +477,13 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Frozen, - displayNameState = DisplayNameState.Display(displayName = "Tangem", onClick = {}), + displayNameState = DisplayNameState.Editing( + displayName = "Tangem", + editingValue = TextFieldValue(text = "movet", selection = TextRange("movet".length)), + onValueChanged = {}, + onSubmit = {}, + onDismiss = {}, + ), ), TangemPayCardDetailsUM( isLoading = false, @@ -505,7 +496,13 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), + displayNameState = DisplayNameState.Editing( + displayName = "Tangem", + editingValue = TextFieldValue(text = ""), + onValueChanged = {}, + onSubmit = {}, + onDismiss = {}, + ), ), TangemPayCardDetailsUM( isLoading = false, @@ -518,7 +515,11 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide onCopy = { _, _ -> }, isHidden = true, cardFrozenState = TangemPayCardFrozenState.Pending, - displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), + displayNameState = DisplayNameState.Display( + displayName = "Tangem Pay Card", + onClick = {}, + isEditingEnabled = true, + ), ), TangemPayCardDetailsUM( isLoading = false, @@ -531,7 +532,11 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide expiry = "12/34", cvv = "123", cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), + displayNameState = DisplayNameState.Display( + displayName = "Tangem Pay Card", + onClick = {}, + isEditingEnabled = false, + ), ), ), ) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 4b8dbc7446..5217c1be53 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -8,18 +8,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.exclude -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.LazyListScope @@ -46,11 +35,7 @@ import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.DisplayNameState -import com.tangem.features.tangempay.entity.TangemPayCardDetailsUM -import com.tangem.features.tangempay.entity.TangemPayCardPageSetting -import com.tangem.features.tangempay.entity.TangemPayCardPageUM -import com.tangem.features.tangempay.entity.TangemPayDailyLimitBlockState +import com.tangem.features.tangempay.entity.* import kotlinx.collections.immutable.ImmutableList private const val CONTENT_FADE_DURATION_MS = 300 @@ -217,7 +202,11 @@ private fun preview() = TangemThemePreview { onCopy = { _, _ -> }, onClick = {}, cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), + displayNameState = DisplayNameState.Display( + displayName = "Tangem Pay Card", + onClick = {}, + isEditingEnabled = true, + ), ), ), cardDetailsState = TangemPayCardDetailsUM( @@ -228,7 +217,11 @@ private fun preview() = TangemThemePreview { onCopy = { _, _ -> }, onClick = {}, cardFrozenState = TangemPayCardFrozenState.Unfrozen, - displayNameState = DisplayNameState.Display(displayName = "Tangem Pay Card", onClick = {}), + displayNameState = DisplayNameState.Display( + displayName = "Tangem Pay Card", + onClick = {}, + isEditingEnabled = false, + ), ), ) } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 2349905b14..c6f426ec27 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -56,10 +56,7 @@ import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTrans import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent import com.tangem.features.tangempay.details.impl.R -import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig -import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM +import com.tangem.features.tangempay.entity.* import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf @@ -116,6 +113,17 @@ internal fun TangemPayDetailsScreen( SpacerH12() }, ) + if (state.addToWalletBlockState != null) { + item( + key = AddToWalletBlockState::class.java, + content = { + TangemPayAddToWalletBlock( + state = state.addToWalletBlockState, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) + }, + ) + } if (state.accountDeactivatedNotificationConfig != null) { item( key = "DEACTIVATION_MESSAGE", @@ -420,6 +428,7 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider Date: Thu, 7 May 2026 07:42:36 +0000 Subject: [PATCH 173/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 94aa5668ec..7179ef2644 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1494" +tangemBlockchainSdk = "releases-5.38-1503" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-603" +tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From a075d9737bbc7c480eaec48771ba192b58141fe3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 7 May 2026 01:10:09 -0700 Subject: [PATCH 174/206] Updated on 2026-08-14 --- .../domain/scanCard/chains/AnalyticsChain.kt | 3 +- .../sdk/impl/DefaultTangemSdkManager.kt | 3 +- .../core/analytics/models/AnalyticsEvent.kt | 27 +++++++++- .../com/tangem/core/analytics/Analytics.kt | 16 +++--- .../tangempay/TangemPayAnalyticsEvents.kt | 22 ++++++-- .../setup/TangemPayCardLimitSetupModel.kt | 6 +++ .../tangempay/model/TangemPayCardPageModel.kt | 7 ++- .../setup/TangemPayCardLimitSetupModelTest.kt | 54 ++++++++++++++----- 8 files changed, 107 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/AnalyticsChain.kt b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/AnalyticsChain.kt index c7c56864ec..06314f32d7 100644 --- a/app/src/main/java/com/tangem/tap/domain/scanCard/chains/AnalyticsChain.kt +++ b/app/src/main/java/com/tangem/tap/domain/scanCard/chains/AnalyticsChain.kt @@ -25,9 +25,8 @@ class AnalyticsChain( val interceptor = CardContextInterceptor(previousChainResult) val params = event.params.toMutableMap() interceptor.intercept(params) - event.params = params.toMap() - Analytics.send(event) + Analytics.send(event.withParams(params.toMap())) return previousChainResult.right() } diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index c4a6e936fc..797c3ce6da 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -238,9 +238,8 @@ internal class DefaultTangemSdkManager( val interceptor = CardContextInterceptor(scanResponse) val params = analyticsEvent.params.toMutableMap() interceptor.intercept(params) - analyticsEvent.params = params.toMap() - Analytics.send(event = analyticsEvent) + Analytics.send(event = analyticsEvent.withParams(params.toMap())) } .doOnFailure { tangemError -> (tangemError as? TangemSdkError)?.let { error -> diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt index 8c12f046df..d259b2ba00 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsEvent.kt @@ -6,8 +6,31 @@ package com.tangem.core.analytics.models open class AnalyticsEvent( val category: String, val event: String, - var params: Map = mapOf(), + val params: Map = mapOf(), ) { - val id: String = "[$category] $event" + + fun withParams(newParams: Map) = AnalyticsEvent(category, event, newParams) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AnalyticsEvent + + if (category != other.category) return false + if (event != other.event) return false + if (params != other.params) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = category.hashCode() + result = 31 * result + event.hashCode() + result = 31 * result + params.hashCode() + result = 31 * result + id.hashCode() + return result + } } \ No newline at end of file diff --git a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt index d6571cb78c..5b93790744 100644 --- a/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt +++ b/core/analytics/src/main/java/com/tangem/core/analytics/Analytics.kt @@ -91,16 +91,16 @@ object Analytics : GlobalAnalyticsEventHandler { if (event is OneTimePerSessionEvent && !shouldSendThrottledEvent(event)) { return@launch } - event.params = applyParamsInterceptors(event) - val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(event) } + val eventWithParams = event.withParams(applyParamsInterceptors(event)) + val eventFilter = analyticsFilters.firstOrNull { it.canBeAppliedTo(eventWithParams) } analyticsMutex.withLock { when { - eventFilter == null -> analyticsHandlers.forEach { handler -> handler.send(event) } - eventFilter.canBeSent(event) -> { + eventFilter == null -> analyticsHandlers.forEach { handler -> handler.send(eventWithParams) } + eventFilter.canBeSent(eventWithParams) -> { analyticsHandlers - .filter { handler -> eventFilter.canBeConsumedByHandler(handler, event) } - .forEach { handler -> handler.send(event) } + .filter { handler -> eventFilter.canBeConsumedByHandler(handler, eventWithParams) } + .forEach { handler -> handler.send(eventWithParams) } } } } @@ -109,10 +109,10 @@ object Analytics : GlobalAnalyticsEventHandler { override fun sendErrorEvent(event: AnalyticsEvent) { analyticsScope.launch { - event.params = applyParamsInterceptors(event) + val eventWithParams = event.withParams(applyParamsInterceptors(event)) analyticsMutex.withLock { analyticsHandlers.filterIsInstance() - .forEach { handler -> handler.sendErrorEvent(event) } + .forEach { handler -> handler.sendErrorEvent(eventWithParams) } } } } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt index a8229e803a..5b3bc6f428 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/tangempay/TangemPayAnalyticsEvents.kt @@ -194,20 +194,36 @@ sealed class TangemPayAnalyticsEvents( ) class ReplaceCardClicked : TangemPayAnalyticsEvents( - categoryName = "Visa Screen", + categoryName = "Visa Card Management", event = "Visa Replace Card Clicked", ) class ReplaceCardConfirmationPopupOpened : TangemPayAnalyticsEvents( - categoryName = "Visa Screen", + categoryName = "Visa Card Management", event = "Visa Replace Card Confirmation Popup Opened", ) class ReplaceCardConfirmed : TangemPayAnalyticsEvents( - categoryName = "Visa Screen", + categoryName = "Visa Card Management", event = "Visa Replace Card Confirmed", ) + class LimitChangeClicked : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Daily Limit Change Clicked", + ) + + class LimitManagementOpened : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Limit Management Screen Opened", + ) + + data class LimitChangeConfirmed(val amount: String) : TangemPayAnalyticsEvents( + categoryName = "Visa Card Management", + event = "Visa Set Limits Confirmed", + params = mapOf("amount" to amount), + ) + class MainVisaPermanentBannerClicked : TangemPayAnalyticsEvents( categoryName = "Visa Onboarding", event = "Visa Permanent Banner Clicked", diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index 487e568445..ff13c3a2b7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.limit.setup import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -20,6 +21,7 @@ import com.tangem.domain.models.account.requireCardWithId import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.navigation.TangemPayCardDetailsInnerRoute @@ -32,6 +34,7 @@ import java.math.BigDecimal import java.util.Currency import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayCardLimitSetupModel @Inject constructor( @@ -41,6 +44,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val setTangemPayCardLimitUseCase: SetTangemPayCardLimitUseCase, private val uiMessageSender: UiMessageSender, + private val analytics: AnalyticsEventHandler, ) : Model() { private val params: TangemPayDetailsContainerComponent.Params = paramsContainer.require() @@ -66,6 +70,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( ) init { + analytics.send(TangemPayAnalyticsEvents.LimitManagementOpened()) observeCardState() } @@ -130,6 +135,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( val amount = uiState.value.amountFieldModel.value.toBigDecimalOrNull() ?: return modelScope.launch { uiState.update { it.copy(isSubmitButtonLoading = true) } + analytics.send(TangemPayAnalyticsEvents.LimitChangeConfirmed(amount.toPlainString())) setTangemPayCardLimitUseCase( cardId = params.config.cardId, userWalletId = params.userWalletId, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 735d0b9398..14d8765c16 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -100,7 +100,7 @@ internal class TangemPayCardPageModel @Inject constructor( val symbol = getJavaCurrencyByCode(status.currencyCode).symbol fiat(status.currencyCode, symbol) }, - onChangeClick = { router.push(TangemPayCardDetailsInnerRoute.LimitSetup) }, + onChangeClick = ::onClickLimitChange, ) } else { TangemPayDailyLimitBlockState.Error @@ -132,6 +132,11 @@ internal class TangemPayCardPageModel @Inject constructor( ) } + private fun onClickLimitChange() { + analytics.send(TangemPayAnalyticsEvents.LimitChangeClicked()) + router.push(TangemPayCardDetailsInnerRoute.LimitSetup) + } + private fun onClickChangePIN(isPinSet: Boolean) { if (!isPinSet) { router.push(TangemPayCardDetailsInnerRoute.ChangePIN) diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index f9a1defa96..6de5f4d546 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.limit.setup import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender @@ -15,21 +16,21 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayDetailsConfig import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.flow.flowOf import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.math.BigDecimal -@TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class TangemPayCardLimitSetupModelTest { private val cardId = "test_card_id" @@ -39,6 +40,7 @@ internal class TangemPayCardLimitSetupModelTest { private val uiMessageSender: UiMessageSender = mockk(relaxed = true) private val setLimitUseCase: SetTangemPayCardLimitUseCase = mockk(relaxed = true) private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() + private val analytics: AnalyticsEventHandler = mockk(relaxed = true) private val params = TangemPayDetailsContainerComponent.Params( userWalletId = userWalletId, @@ -90,6 +92,7 @@ internal class TangemPayCardLimitSetupModelTest { paymentAccountStatusSupplier = paymentAccountStatusSupplier, setTangemPayCardLimitUseCase = setLimitUseCase, uiMessageSender = uiMessageSender, + analytics = analytics, ) } @@ -119,7 +122,29 @@ internal class TangemPayCardLimitSetupModelTest { } @Nested - @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Analytics { + @Test + fun `GIVEN model WHEN init THEN LimitManagementOpened is sent`() { + val model = createModel() + + verify(exactly = 1) { analytics.send(TangemPayAnalyticsEvents.LimitManagementOpened()) } + + model.onDestroy() + } + + @Test + fun `GIVEN model WHEN onSubmitClick THEN LimitChangeConfirmed is sent`() { + val model = createModel() + + model.uiState.value.amountFieldModel.onValueChange("100") + model.uiState.value.onSubmitClick() + verify(exactly = 1) { analytics.send(TangemPayAnalyticsEvents.LimitChangeConfirmed("100")) } + + model.onDestroy() + } + } + + @Nested inner class Presets { @Test @@ -143,14 +168,17 @@ internal class TangemPayCardLimitSetupModelTest { } } - private fun provideTestCases() = listOf( - Arguments.of("0", false), - Arguments.of("0.99", false), - Arguments.of("1", true), - Arguments.of("100", true), - Arguments.of("-1", false), - Arguments.of("", false), - Arguments.of("abc", true), - Arguments.of("1001", false), - ) + private companion object { + @JvmStatic + fun provideTestCases() = listOf( + Arguments.of("0", false), + Arguments.of("0.99", false), + Arguments.of("1", true), + Arguments.of("100", true), + Arguments.of("-1", false), + Arguments.of("", false), + Arguments.of("abc", true), + Arguments.of("1001", false), + ) + } } \ No newline at end of file From 0a34e912f68a51d4d7473f5b84fad211a44dba92 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 11:52:13 +0500 Subject: [PATCH 175/206] Updated on 2026-08-14 --- .../java/com/tangem/feature/swap/ui/TransactionCard.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt index 65aad222b7..c4c9307346 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/TransactionCard.kt @@ -382,8 +382,12 @@ private fun Content( onFocusChange = type.onFocusChanged, ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() + LaunchedEffect(type.isEnabled) { + if (type.isEnabled) { + focusRequester.requestFocus() + } else { + focusRequester.freeFocus() + } } } } From a4e8e059a7312e85acb110db84f29502a686f61a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 17:43:42 +0500 Subject: [PATCH 176/206] Updated on 2026-08-14 --- .../common/ui/notifications/NotificationUM.kt | 8 +++++++ .../ui/notifications/NotificationsFactory.kt | 24 +++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt index fe9115cfec..893ca50431 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt @@ -170,6 +170,14 @@ sealed class NotificationUM(val config: NotificationConfig) { subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text), ) + data class NetworkAccountNotFunded(val coinName: String) : Error( + title = resourceReference(R.string.alert_failed_to_send_transaction_title), + subtitle = resourceReference( + id = R.string.no_account_generic, + formatArgs = wrappedList(coinName), + ), + ) + data object DestinationTagRequired : Error( title = resourceReference(id = R.string.send_validation_destination_tag_required_title), subtitle = resourceReference(id = R.string.send_validation_destination_tag_required_description), diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt index 4e0e7d90e5..b52325095e 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationsFactory.kt @@ -136,14 +136,24 @@ object NotificationsFactory { // No need to show reserve amount warning if fee currency is unknown for token transfer return } else if (!isAccountFunded && reserveAmount != null && reserveAmount > sendingCoinAmount) { - add( - NotificationUM.Error.ReserveAmount( - reserveAmount.format { - crypto(feeCryptoCurrency ?: cryptoCurrency) - }, - ), - ) + // account not funded, sending coin amount < reserve (send coin with less amount OR send any token) + + if (cryptoCurrency is CryptoCurrency.Coin) { + // Try to send coin amount less than reserve amount (e.g. less than 1 XLM in Stellar) + add( + NotificationUM.Error.ReserveAmount( + reserveAmount.format { + crypto(feeCryptoCurrency ?: cryptoCurrency) + }, + ), + ) + } else { + checkNotNull(feeCryptoCurrency) + // Try to send any token (e.g. USDC in Stellar, but account not funded -> user must send XLM at first) + add(NotificationUM.Error.NetworkAccountNotFunded(coinName = feeCryptoCurrency.name)) + } } + // TODO: check the RECEIVER account trustline before sending } fun MutableList.addMinimumAmountErrorNotification( From 0d8970b386f5e78f0c2b993a2a45af1007f6473a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 05:45:30 -0700 Subject: [PATCH 177/206] Updated on 2026-08-14 --- .../format/bigdecimal/BigDecimalFiatFormat.kt | 27 ++++++++++++++ .../bigdecimal/BigDecimalFiatFormatTest.kt | 36 +++++++++++++++++++ .../setup/TangemPayCardLimitSetupModel.kt | 10 +++--- .../tangempay/model/TangemPayCardPageModel.kt | 3 +- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt index 7629ed6986..af950e8bfe 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt @@ -242,6 +242,33 @@ fun BigDecimalFiatFormat.anyDecimals(decimals: Int): BigDecimalFormat = BigDecim .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) } +/** + * Formats fiat amount following the pattern: + * + * 123 -> $123 + * + * 123.1 -> $123.10 + * + * 123.10 -> $123.10 + * + * 123.456 -> $123.46 + */ +fun BigDecimalFiatFormat.optionalDecimals(): BigDecimalFormat = BigDecimalFormat { value -> + val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode) + + val hasFraction = value.stripTrailingZeros().scale() > 0 + val defaultDigits = formatterCurrency.defaultFractionDigits + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = formatterCurrency + minimumFractionDigits = if (hasFraction) defaultDigits else 0 + maximumFractionDigits = defaultDigits + roundingMode = RoundingMode.HALF_UP + } + + formatter.format(value) + .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) +} + // == Helpers == private fun BigDecimal.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt index fcc2eca898..a5133d16d0 100644 --- a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt @@ -2,6 +2,9 @@ package com.tangem.core.ui.format.bigdecimal import com.google.common.truth.Truth import org.junit.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import java.math.BigDecimal import java.util.Locale @@ -342,4 +345,37 @@ internal class BigDecimalFiatFormatTest { Truth.assertThat(formatted) .isEqualTo("-" + "0.50".addUsdSymbolLeft()) } + + @ParameterizedTest + @MethodSource("provideTestCasesForOptionalDecimals") + fun `GIVEN amount WHEN format with optionalDecimals THEN correct answer`( + amount: String, + answer: String, + ) { + val testValue = BigDecimal(amount) + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).optionalDecimals() + } + + Truth.assertThat(formatted).isEqualTo(answer) + } + + private companion object { + @JvmStatic + fun provideTestCasesForOptionalDecimals() = listOf( + Arguments.of("123", "$123"), + Arguments.of("123.1", "$123.10"), + Arguments.of("123.10", "$123.10"), + Arguments.of("123.456", "$123.46"), + Arguments.of("0", "$0"), + Arguments.of("0.1", "$0.10"), + Arguments.of("0.12", "$0.12"), + Arguments.of("0.127", "$0.13"), + ) + } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt index ff13c3a2b7..cd273833c5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModel.kt @@ -9,10 +9,10 @@ import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.WrappedList -import com.tangem.core.ui.format.bigdecimal.anyDecimals import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.format.bigdecimal.optionalDecimals import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue @@ -170,7 +170,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( id = R.string.tangempay_card_limit_setup_amount_subtitle, formatArgs = WrappedList( listOf( - MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() }, ), ), ) @@ -179,8 +179,8 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( id = R.string.tangempay_daily_limit_hint, formatArgs = WrappedList( listOf( - MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, - maxLimit.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) }, + MIN_LIMIT.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() }, + maxLimit.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() }, ), ), ) @@ -193,7 +193,7 @@ internal class TangemPayCardLimitSetupModel @Inject constructor( BigDecimal("10000"), BigDecimal("25000"), ).map { preset -> - val label = preset.format { fiat(currency.currencyCode, currency.symbol).anyDecimals(0) } + val label = preset.format { fiat(currency.currencyCode, currency.symbol).optionalDecimals() } TangemPayCardLimitSetupUM.LimitPresetUM( label = label, onClick = { onPresetClick(preset) }, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 14d8765c16..f5c7e0a5e5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -17,6 +17,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode +import com.tangem.core.ui.format.bigdecimal.optionalDecimals import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig @@ -98,7 +99,7 @@ internal class TangemPayCardPageModel @Inject constructor( TangemPayDailyLimitBlockState.Content( limit = limit.amount.format { val symbol = getJavaCurrencyByCode(status.currencyCode).symbol - fiat(status.currencyCode, symbol) + fiat(status.currencyCode, symbol).optionalDecimals() }, onChangeClick = ::onClickLimitChange, ) From 06fd799843b266b4801f3a1086e9e7e1c9c692c4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 8 May 2026 15:45:57 +0300 Subject: [PATCH 178/206] Updated on 2026-08-14 --- .../kotlin/com/tangem/scenarios/MarketsScenarios.kt | 3 +-- .../kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt | 2 +- .../kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt index 9c3b33b013..cda67fda49 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MarketsScenarios.kt @@ -3,7 +3,6 @@ package com.tangem.scenarios import com.tangem.common.BaseTestCase import com.tangem.common.extensions.SwipeDirection import com.tangem.common.extensions.clickWithAssertion -import com.tangem.common.extensions.swipeMarketsBlock import com.tangem.common.extensions.swipeVertical import com.tangem.screens.onMainScreen import com.tangem.screens.onMarketsExchangesScreen @@ -55,7 +54,7 @@ fun BaseTestCase.openMarketsScreen() { synchronizeAddresses() } step("Open 'Markets' screen") { - swipeMarketsBlock(SwipeDirection.UP) + onMainScreen { searchThroughMarketPlaceholder.performClick() } waitForIdle() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt index 45000fa4ad..b8a7826147 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/balance/TotalBalanceUpdateTest.kt @@ -73,7 +73,7 @@ class TotalBalanceUpdateTest : BaseTestCase() { onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } } step("Open 'Markets screen'") { - swipeMarketsBlock(SwipeDirection.UP) + onMainScreen { searchThroughMarketPlaceholder.performClick() } waitForIdle() } step("Click on $tokenTitle token") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt index fd470cb89d..fc17e54653 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/markets/MarketsExchangesTest.kt @@ -12,6 +12,7 @@ import com.tangem.scenarios.assertMarketsExchangesScreen import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openMarketsExchangesScreen import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onMainScreen import com.tangem.screens.onMarketsExchangesScreen import com.tangem.screens.onMarketsScreen import dagger.hilt.android.testing.HiltAndroidTest @@ -52,7 +53,7 @@ class MarketsExchangesTest : BaseTestCase() { synchronizeAddresses() } step("Open 'Markets' screen") { - swipeMarketsBlock(SwipeDirection.UP) + onMainScreen { searchThroughMarketPlaceholder.performClick() } waitForIdle() } step("Click on '$tokenName' token") { From f397a279b1569793033765ab3d281a5d6409d980 Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 9 May 2026 01:01:51 +0500 Subject: [PATCH 179/206] Updated on 2026-08-14 --- .../feature/swap/domain/SwapInteractorImpl.kt | 34 ++++++++++--------- .../swap/model/SwapNotificationsFactory.kt | 16 +++++---- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 9fc28e116c..09b5c11731 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -1223,6 +1223,23 @@ internal class SwapInteractorImpl @Inject constructor( }, ) + val fee = when (txFeeSealedState) { + is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value + is TxFeeSealedState.Legacy -> { + when (val txFee = txFeeSealedState.txFeeState) { + TxFeeState.Empty -> BigDecimal.ZERO + is TxFeeState.MultipleFeeState -> txFee.priorityFee.fee.amount.value + is TxFeeState.SingleFeeState -> txFee.fee.fee.amount.value + } + } + } + + val feeState = getFeeState( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + fee = fee, + spendAmount = amount, + ) + when (provider.type) { ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> { val state = updatePermissionState( @@ -1236,26 +1253,11 @@ internal class SwapInteractorImpl @Inject constructor( state.copy( preparedSwapConfigState = state.preparedSwapConfigState.copy( isBalanceEnough = isBalanceWithoutFeeEnough, + feeState = feeState, ), ) } ExchangeProviderType.CEX -> { - val fee = when (txFeeSealedState) { - is TxFeeSealedState.Component -> txFeeSealedState.txFee.fee.amount.value - is TxFeeSealedState.Legacy -> { - when (val txFee = txFeeSealedState.txFeeState) { - TxFeeState.Empty -> BigDecimal.ZERO - is TxFeeState.MultipleFeeState -> txFee.priorityFee.fee.amount.value - is TxFeeState.SingleFeeState -> txFee.fee.fee.amount.value - } - } - } - - val feeState = getFeeState( - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - fee = fee, - spendAmount = amount, - ) swapState.copy( permissionState = PermissionDataState.Empty, preparedSwapConfigState = PreparedSwapConfigState( diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 2bff6bb705..6f203e2e72 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -306,23 +306,25 @@ internal class SwapNotificationsFactory( ) { if (hideFee) return val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency - val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough ?: return - val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough && + val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough + val shouldShowCoverWarning = !quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && feeCryptoCurrencyStatus?.currency != fromCurrency - val isNotEnoughFee = + val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX + + val isNotEnoughFee = feeEnoughState is SwapFeeState.NotEnough && !isCEXProvider || quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough - val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) && - quoteModel.swapProvider.type == ExchangeProviderType.CEX + val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider + if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) { add( SwapNotificationUM.Error.UnableToCoverFeeWarning( fromToken = fromCurrency, feeCurrency = feeCryptoCurrencyStatus?.currency, - currencyName = feeEnoughState.currencyName ?: fromCurrency.network.name, - currencySymbol = feeEnoughState.currencySymbol ?: fromCurrency.network.currencySymbol, + currencyName = feeEnoughState?.currencyName ?: fromCurrency.network.name, + currencySymbol = feeEnoughState?.currencySymbol ?: fromCurrency.network.currencySymbol, onConfirmClick = actions.onBuyClick, ), ) From ae6ec0e02a7d87539aea7b49f8dd8dedb0b44f54 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 May 2026 10:16:33 +0100 Subject: [PATCH 180/206] Updated on 2026-08-14 --- .../src/main/res/values-pt-rBR/strings.xml | 13 + core/res/src/main/res/values-ru/strings.xml | 3 + core/res/src/main/res/values/strings.xml | 4 + .../analytics/WalletSettingsAnalyticEvents.kt | 28 +- features/hot-wallet/impl/build.gradle.kts | 11 + ...letBackupUM.kt => WalletBackupContract.kt} | 2 +- .../walletbackup/model/WalletBackupModel.kt | 33 ++- .../walletbackup/ui/WalletBackupContent.kt | 8 +- .../model/WalletBackupModelTest.kt | 278 ++++++++++++++++++ 9 files changed, 356 insertions(+), 24 deletions(-) rename features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/{WalletBackupUM.kt => WalletBackupContract.kt} (95%) create mode 100644 features/hot-wallet/impl/src/test/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModelTest.kt diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 45dbb43d03..4cd264c962 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -517,6 +517,7 @@ Foram encontrados fundos em endereços adicionais. Ative os Endereços Dinâmicos para acessá-los. Fundos encontrados em endereços adicionais Endereço dinâmico + O gerenciamento de endereços dinâmicos estará disponível assim que as transações pendentes estiverem na rede. %@ está completo Melhores oportunidades Limpar filtro A lista está temporariamente vazia, pois está sendo atualizada. Volte daqui a pouco. @@ -595,6 +596,7 @@ Disponível em %s Indisponível para este par Permissão necessária + É necessária permissão. Recomendado Comprado %s Comprando %s @@ -641,6 +643,7 @@ A função Aprovar é necessária para conceder permissão a outro endereço para usar uma quantidade específica de seus tokens. Por definição, os contratos inteligentes não podem acessar seus tokens a menos que você aprove. Ao \"desbloquear\" seus tokens, você autoriza o contrato inteligente StakeKit a usá-los. Os mineradores da rede recebem uma taxa de gás (paga por você) para registrar essa ação no blockchain. Você pode fazer staking de seus tokens após conceder a aprovação. Para continuar, você precisa permitir que o contrato inteligente da Polygon use seus dados. %s Para continuar, conceda %1s permissão de contratos inteligentes para usar seu %2s + As corretoras descentralizadas exigem permissão para interagir com sua carteira. %1s Conceder permissão Ilimitado Os endereços são gerados diretamente na sua carteira de hardware Tangem — prontos para usar e totalmente protegidos. @@ -1115,6 +1118,14 @@ Esta transação já foi processada. Nenhuma ação adicional é necessária. Obtendo as melhores taxas... Instantâneo + A verificação é gratuita e geralmente leva de 1 a 2 minutos. + A Tangem não terá acesso às suas informações de identidade; você compartilha os dados diretamente com o provedor regulamentado. + A verificação desbloqueia o acesso total a transações futuras com este fornecedor. + Escolha outro método + Para cumprir os requisitos regulamentares locais %@ Requer verificação de identidade. + Verificação de identidade exigida pelo provedor de pagamento + Verificar + O que é importante Ao utilizar a funcionalidade de acesso prioritário, você concorda com os termos do provedor. %1$s e %2$s O serviço é fornecido por um provedor externo. A Tangem não se responsabiliza por ele. O valor da compra não deve ser superior a %s @@ -1181,6 +1192,8 @@ Cartão de crédito ou conta bancária Compartilhe seu endereço ou código QR. Entre seus portfólios + Outro + Recarga rápida Não é necessário memorando %1$s (%2$s) sobre %3$s rede %1$s sobre %2$s rede diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index 0a6290a4fb..b8e2e9e0e4 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -293,11 +293,13 @@ Удалить Отключить Отключено + Выключение Отключить Готово Изменить Включить Включено + Включение Ошибка Комиссия сети Обменять @@ -347,6 +349,7 @@ %dмин назад месяц + Еще Комиссия сети Сумма отправки будет уменьшена на %1$s (%2$s) для покрытия выбранного уровня комиссии diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index e20ccf50f8..f8c7c79e89 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -596,6 +596,7 @@ Available from %s Unavailable for this pair Permission Required + Permission needed Recommended Bought %s Buying %s @@ -643,6 +644,7 @@ The Approve function is needed to grant permission to another address to use a specific amount of your tokens. By design, smart contracts can\'t access your tokens unless you approve. By \"unlocking\" your tokens, you authorize the StakeKit smart contract to use them. The network\'s miners receive a gas fee (paid by you) to record this action on the blockchain. You can stake your token after giving approval. To continue you need to allow Polygon smart contract to use your %s To continue, grant %1s smart contracts permission to use your %2s + Decentralized exchanges require permission to interact with your wallet. %1s Give Permission Unlimited Addresses are generated directly on your Tangem hardware wallet — ready to use and fully protected. @@ -670,6 +672,8 @@ Keeps your crypto safe and offline. Slim as a credit card, safer than a bank vault. If you do, you\'ll need to start over. Recover existing wallet via Google Drive backup + We\'re working on Google Drive backup to make wallet recovery even easier. + Google Drive backup is coming soon Google Drive backup Create a secure wallet and transfer your funds for extra protection. Create new wallet diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt index 850782d36a..b8c7e1c1f4 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/WalletSettingsAnalyticEvents.kt @@ -64,14 +64,18 @@ sealed class WalletSettingsAnalyticEvents( event = "Button - Recovery phrase", ) + class ButtonGoogleDriveBackup : WalletSettingsAnalyticEvents( + event = "Button - Cloud Backup", + ) + data class NoticeBackupFirst( val source: String, val action: Action, ) : WalletSettingsAnalyticEvents( event = "Notice - Backup First", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action.value, + AnalyticsParam.SOURCE to source, + ACTION to action.value, ), ) { enum class Action(val value: String) { @@ -111,8 +115,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Recovery Phrase Screen Info", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -122,8 +126,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Recovery Phrase Screen", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -133,8 +137,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Recovery Phrase Check", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -144,8 +148,8 @@ sealed class WalletSettingsAnalyticEvents( ) : WalletSettingsAnalyticEvents( event = "Backup Complete Screen", params = mapOf( - AnalyticsParam.Key.SOURCE to source, - AnalyticsParam.Key.ACTION to action, + AnalyticsParam.SOURCE to source, + ACTION to action, ), ) @@ -153,14 +157,14 @@ sealed class WalletSettingsAnalyticEvents( val source: String, ) : WalletSettingsAnalyticEvents( event = "Access Code Screen Opened", - params = mapOf(AnalyticsParam.Key.SOURCE to source), + params = mapOf(AnalyticsParam.SOURCE to source), ) data class ReEnterAccessCodeScreen( val source: String, ) : WalletSettingsAnalyticEvents( event = "Re-enter Access Code Screen", - params = mapOf(AnalyticsParam.Key.SOURCE to source), + params = mapOf(AnalyticsParam.SOURCE to source), ) class ButtonStartUpgrade : WalletSettingsAnalyticEvents( diff --git a/features/hot-wallet/impl/build.gradle.kts b/features/hot-wallet/impl/build.gradle.kts index 9d2eadef6e..5fb36dc68f 100644 --- a/features/hot-wallet/impl/build.gradle.kts +++ b/features/hot-wallet/impl/build.gradle.kts @@ -11,6 +11,10 @@ android { namespace = "com.tangem.features.hotwallet.impl" } +tasks.withType().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) } \ No newline at end of file diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt similarity index 95% rename from features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt rename to features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt index a1219ed46a..a7a4115f71 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupUM.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/entity/WalletBackupContract.kt @@ -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 { diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt index 32986a6549..6eb1b82ac3 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModel.kt @@ -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) diff --git a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt index dc2787f9c6..59fb6abcdd 100644 --- a/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt +++ b/features/hot-wallet/impl/src/main/kotlin/com/tangem/features/hotwallet/walletbackup/ui/WalletBackupContent.kt @@ -132,7 +132,7 @@ private class WalletBackupUMProvider : CollectionPreviewParameterProvider() } 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 { 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 { 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().right() + + val model = createModel(this) + advanceUntilIdle() + + model.uiState.value.onRecoveryPhraseClick() + advanceUntilIdle() + + verify { analyticsEventHandler.send(match { 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 { 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 { 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 { 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 { 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 { true } + ) + } + verify { + uiMessageSender.send( + match { + 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, + ) + } +} \ No newline at end of file From 2008b259e2b67c09827a9f72c383e6e7db3ff6dc Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 May 2026 14:46:45 +0300 Subject: [PATCH 181/206] Updated on 2026-08-14 --- .../wallet/presentation/wallet/domain/Wallet2CobrandImage.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt index c844afcbdf..d802da3a1a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/Wallet2CobrandImage.kt @@ -354,7 +354,7 @@ internal enum class Wallet2CobrandImage( WinterSakura( cards2ResId = R.drawable.ill_winter_sakura_card2_120_106, cards3ResId = R.drawable.ill_winter_sakura_card3_120_106, - batchIds = setOf("AF990053", "AF990054", "AF990055"), + batchIds = setOf("AF990053", "AF990054", "AF990055", "AF990074", "AF990075", "AF990076"), ), LockedMoney( From 183ab2eab217211ac7d2d1ec578c852bbeff5381 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 May 2026 17:55:07 +0300 Subject: [PATCH 182/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 7179ef2644..76d48569f9 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1503" +tangemBlockchainSdk = "releases-5.38-1512" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From 34fed24784e8ed20decbc038afea122639b23aa6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 13 May 2026 16:52:05 +0000 Subject: [PATCH 183/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index cbb84649f4..76d48569f9 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1508" +tangemBlockchainSdk = "releases-5.38-1512" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-603" +tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From a2480ff02102de1073a49cb0778687ad797bf933 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 14 May 2026 13:45:16 +0500 Subject: [PATCH 184/206] Updated on 2026-08-14 --- .../tangem/core/analytics/models/AnalyticsParam.kt | 1 + .../domain/card/analytics/IntroductionProcess.kt | 11 +++++++++-- .../createwalletstart/CreateWalletStartModel.kt | 5 +++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index d6b77eb6ba..9a0e69c86b 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -297,6 +297,7 @@ sealed class AnalyticsParam { const val REFERRAL_ID = "Referral_ID" const val SEARCHED = "Searched" const val RATE_TYPE = "Rate Type" + const val SCREEN_TYPE = "Screen Type" } } diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt index 98114357c7..cf48b96aea 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/analytics/IntroductionProcess.kt @@ -15,20 +15,27 @@ sealed class IntroductionProcess( class ButtonScanCardLegacy : IntroductionProcess("Button - Scan Card") class CreateWalletIntroScreenOpened( + screenType: ScreenType, referralId: String?, ) : IntroductionProcess( event = "Create Wallet Intro Screen Opened", params = buildMap { + put(AnalyticsParam.SCREEN_TYPE, screenType.value) putAll(getReferralParams(referralId)) }, - ) + ) { + enum class ScreenType(val value: String) { + Cold("Cold Wallet"), + Hot("Mobile Wallet"), + } + } class ButtonScanCard( val source: AnalyticsParam.ScreensSources, ) : IntroductionProcess( event = "Button - Scan Card", params = mapOf( - AnalyticsParam.Key.SOURCE to source.value, + AnalyticsParam.SOURCE to source.value, ), ) } \ No newline at end of file diff --git a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt index 71b570076c..22f08deb02 100644 --- a/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt +++ b/features/create-wallet-start/impl/src/main/kotlin/com/tangem/features/createwalletstart/CreateWalletStartModel.kt @@ -23,6 +23,7 @@ import com.tangem.core.ui.message.dialog.Dialogs.hotWalletCreationNotSupportedDi import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.analytics.IntroductionProcess +import com.tangem.domain.card.analytics.IntroductionProcess.CreateWalletIntroScreenOpened.ScreenType import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.SaveWalletError @@ -137,6 +138,10 @@ internal class CreateWalletStartModel @Inject constructor( modelScope.launch { analyticsEventHandler.send( event = IntroductionProcess.CreateWalletIntroScreenOpened( + screenType = when (params.mode) { + CreateWalletStartComponent.Mode.ColdWallet -> ScreenType.Cold + CreateWalletStartComponent.Mode.HotWallet -> ScreenType.Hot + }, referralId = appsFlyerStore.get()?.refcode, ), ) From ba8797ec978260454ccdc55ca91d1b62e90d0377 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 14 May 2026 12:46:01 +0400 Subject: [PATCH 185/206] Updated on 2026-08-14 --- .../approval/impl/model/GiveApprovalModel.kt | 10 +- .../impl/model/GiveApprovalModelTest.kt | 193 ++++++++++++++++-- 2 files changed, 183 insertions(+), 20 deletions(-) diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt index 108efdb33c..dbe9d28ed2 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt @@ -18,6 +18,7 @@ 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.navigation.url.UrlOpener +import com.tangem.core.ui.utils.parseBigDecimalOrNull import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.transaction.error.GetFeeError @@ -286,10 +287,9 @@ internal class GiveApprovalModel @Inject constructor( } private fun getApprovalAmount(): BigDecimal? { - return if (uiState.value.approveType == ApproveType.LIMITED) { - params.amount.toBigDecimalOrNull() - } else { - null + return when (uiState.value.approveType) { + ApproveType.LIMITED -> params.amount.parseBigDecimalOrNull() + ApproveType.UNLIMITED -> null } } @@ -302,7 +302,7 @@ internal class GiveApprovalModel @Inject constructor( val tokenCurrency = cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: return GetFeeError.DataError(IllegalStateException("Currency is not a token")).left() - val amount = params.amount.toBigDecimalOrNull() + val amount = params.amount.parseBigDecimalOrNull() ?: return GetFeeError.DataError(IllegalArgumentException("Invalid amount format")).left() val allowance = getAllowanceInfoUseCase( diff --git a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt index cf09525c4c..acf4eaa4da 100644 --- a/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt +++ b/features/approval/impl/src/test/kotlin/com/tangem/features/approval/impl/model/GiveApprovalModelTest.kt @@ -2,16 +2,21 @@ package com.tangem.features.approval.impl.model import arrow.core.right import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer -import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.extensions.TextReference 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.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.AllowanceInfo +import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase import com.tangem.domain.transaction.usecase.GetAllowanceInfoUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase @@ -23,6 +28,7 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.approval.api.GiveApprovalComponent import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk @@ -30,6 +36,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.math.BigDecimal @OptIn(ExperimentalCoroutinesApi::class) class GiveApprovalModelTest { @@ -48,31 +55,73 @@ class GiveApprovalModelTest { private val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF") private val userWallet: UserWallet.Hot = mockk(relaxed = true) - private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk { - every { currency } returns mockk(relaxed = true) + private val tokenCurrency: CryptoCurrency.Token = mockk(relaxed = true) { + every { contractAddress } returns "0xContract" + every { network } returns mockk(relaxed = true) } - private val params = GiveApprovalComponent.Params( - userWalletId = userWalletId, - cryptoCurrencyStatus = cryptoCurrencyStatus, - feeCryptoCurrencyStatus = cryptoCurrencyStatus, - amount = "10", - spenderAddress = "0xSpender", - amountFooter = TextReference.EMPTY, - feeFooter = TextReference.EMPTY, - callback = mockk(relaxed = true), - ) + private val cryptoCurrencyStatus: CryptoCurrencyStatus = mockk { + every { currency } returns tokenCurrency + } + + private val approvalTx: TransactionData.Uncompiled = mockk(relaxed = true) + private val transactionFee: TransactionFee = mockk(relaxed = true) + private val transactionFeeExtended: TransactionFeeExtended = mockk(relaxed = true) { + every { transactionFee } returns this@GiveApprovalModelTest.transactionFee + } private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) @BeforeEach fun setUp() { every { getUserWalletUseCase.invoke(userWalletId) } returns userWallet.right() + + coEvery { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = any(), + contractAddress = any(), + spenderAddress = any(), + ) + } returns approvalTx.right() + + coEvery { + getAllowanceInfoUseCase( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } returns AllowanceInfo.Enough(allowance = BigDecimal.ZERO).right() + + coEvery { + getFeeUseCase(transactionData = any(), userWallet = any(), network = any()) + } returns transactionFee.right() + + coEvery { + getFeeForGaslessUseCase(transactionData = any(), userWallet = any(), network = any()) + } returns transactionFeeExtended.right() + + coEvery { + getFeeForTokenUseCase(transactionData = any(), userWallet = any(), token = any()) + } returns transactionFeeExtended.right() } - private fun createModel(): GiveApprovalModel = GiveApprovalModel( + private fun createParams(amount: String): GiveApprovalComponent.Params = GiveApprovalComponent.Params( + userWalletId = userWalletId, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCryptoCurrencyStatus = cryptoCurrencyStatus, + amount = amount, + spenderAddress = "0xSpender", + amountFooter = TextReference.EMPTY, + feeFooter = TextReference.EMPTY, + callback = mockk(relaxed = true), + ) + + private fun createModel(amount: String = "10"): GiveApprovalModel = GiveApprovalModel( dispatchers = TestingCoroutineDispatcherProvider(), - paramsContainer = MutableParamsContainer(params), + paramsContainer = MutableParamsContainer(createParams(amount)), createApprovalTransactionUseCase = createApprovalTransactionUseCase, getAllowanceInfoUseCase = getAllowanceInfoUseCase, sendTransactionUseCase = sendTransactionUseCase, @@ -109,4 +158,118 @@ class GiveApprovalModelTest { coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() } coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() } } + + @Test + fun `GIVEN comma decimal amount and LIMITED approveType WHEN loadFeeExtended THEN creates approval tx with parsed amount`() = + runTest { + val model = createModel(amount = "1,1") + + val result = model.loadFeeExtended(maybeToken = null) + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = match { it != null && it.compareTo(BigDecimal("1.1")) == 0 }, + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN point decimal amount and LIMITED approveType WHEN loadFeeExtended THEN creates approval tx with parsed amount`() = + runTest { + val model = createModel(amount = "1.1") + + val result = model.loadFeeExtended(maybeToken = null) + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = match { it != null && it.compareTo(BigDecimal("1.1")) == 0 }, + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN comma decimal amount and UNLIMITED approveType WHEN loadFeeExtended THEN creates approval tx with null amount`() = + runTest { + val model = createModel(amount = "1,1") + model.onChangeApproveType(ApproveType.UNLIMITED) + + val result = model.loadFeeExtended(maybeToken = null) + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = isNull(), + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN unparseable amount and LIMITED approveType WHEN loadFeeExtended THEN creates approval tx with null amount`() = + runTest { + val model = createModel(amount = "abc") + + val result = model.loadFeeExtended(maybeToken = null) + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = isNull(), + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN comma decimal amount and LIMITED approveType WHEN loadFee THEN creates approval tx with parsed amount`() = + runTest { + val model = createModel(amount = "2,5") + + val result = model.loadFee() + + assertThat(result.isRight()).isTrue() + coVerify { + createApprovalTransactionUseCase( + cryptoCurrencyStatus = any(), + userWalletId = any(), + amount = match { it != null && it.compareTo(BigDecimal("2.5")) == 0 }, + contractAddress = any(), + spenderAddress = any(), + ) + } + } + + @Test + fun `GIVEN unparseable amount WHEN loadFee THEN returns DataError and does not check allowance`() = runTest { + val model = createModel(amount = "abc") + + val result = model.loadFee() + + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java) + coVerify(exactly = 0) { + getAllowanceInfoUseCase( + userWalletId = any(), + cryptoCurrency = any(), + spenderAddress = any(), + requiredAmount = any(), + ) + } + } } \ No newline at end of file From 152df94941dacd45d532e06f74162cb2b46831cc Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 14 May 2026 01:52:45 -0700 Subject: [PATCH 186/206] Updated on 2026-08-14 --- .../tangempay/ui/TangemPayCardPageScreen.kt | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 5217c1be53..8ea4e2139d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -76,23 +76,25 @@ internal fun TangemPayCardPageScreen( state = cardDetailsState, ) } - if (state.addToWalletBlockState != null) { - cardPageItem(key = "GooglePay") { - TangemPayAddToWalletBlock(state = state.addToWalletBlockState) - } - } - cardPageItem(key = "Limit") { - TangemPayDailyLimitBlock(state = state.dailyLimitState) - } - if (state.dailyLimitState == TangemPayDailyLimitBlockState.Error) { - cardPageItem(key = "LimitError") { - TangemPayDailyLimitErrorBlock() - } - } - cardPageItem(key = "Settings") { - if (state.isReissueInProgress) { + if (state.isReissueInProgress) { + cardPageItem(key = "Reissue") { TangemPayReplacingCardBlock() - } else { + } + } else { + if (state.addToWalletBlockState != null) { + cardPageItem(key = "GooglePay") { + TangemPayAddToWalletBlock(state = state.addToWalletBlockState) + } + } + cardPageItem(key = "Limit") { + TangemPayDailyLimitBlock(state = state.dailyLimitState) + } + if (state.dailyLimitState == TangemPayDailyLimitBlockState.Error) { + cardPageItem(key = "LimitError") { + TangemPayDailyLimitErrorBlock() + } + } + cardPageItem(key = "Settings") { TangemPayCardPageSettingsBlock(settings = state.settings) } } From 1bf8751230406de61c14930a1447dd601cc20573 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 14 May 2026 15:51:28 +0500 Subject: [PATCH 187/206] Updated on 2026-08-14 --- .../local/visa/entity/PaymentAccountStatusValueDM.kt | 1 + .../pay/converter/PaymentAccountStatusValueDMConverter.kt | 2 ++ .../data/pay/flow/DefaultPaymentAccountStatusFetcher.kt | 1 + .../tangem/data/pay/repository/DefaultOnboardingRepository.kt | 3 +++ .../tangem/domain/models/account/PaymentAccountStatusValue.kt | 4 +++- .../main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt | 1 + 6 files changed, 11 insertions(+), 1 deletion(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index d51eed7b2f..1f74827d91 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -45,6 +45,7 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "deposit_address") val depositAddress: String?, @Json(name = "fiat_balance") val fiatBalance: FiatBalanceDM, @Json(name = "crypto_balance") val cryptoBalance: CryptoBalanceDM, + @Json(name = "available_for_withdrawal") val availableForWithdrawal: BigDecimal, @Json(name = "cards") val cards: List, ) : PaymentAccountStatusValueDM diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 23c8ce5ea3..0c2fd37b83 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -41,6 +41,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( depositAddress = value.depositAddress, fiatBalance = value.fiatBalance.toDM(), cryptoBalance = value.cryptoBalance.toDM(), + availableForWithdrawal = value.availableForWithdrawal, cards = value.cards.map { card -> PaymentAccountStatusValueDM.TangemPayCard( id = card.id, @@ -86,6 +87,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( depositAddress = value.depositAddress, fiatBalance = value.fiatBalance.toDomain(), cryptoBalance = value.cryptoBalance.toDomain(), + availableForWithdrawal = value.availableForWithdrawal, cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId), cards = value.cards.map { card -> TangemPayCard( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 2f13c10d8d..bc789c6728 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -289,6 +289,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( depositAddress = cardInfo.depositAddress, fiatBalance = cardInfo.fiatBalance, cryptoBalance = cardInfo.cryptoBalance, + availableForWithdrawal = cardInfo.availableForWithdrawal, cryptoCurrency = cryptoCurrency, cards = listOf( TangemPayCard( diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index a05f3e7b14..d6dd2b330a 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -35,6 +35,7 @@ import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.extensions.orZero import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject @@ -174,6 +175,7 @@ internal class DefaultOnboardingRepository @Inject constructor( val card = response?.card val fiatBalance = response?.balance?.fiat val cryptoBalance = response?.balance?.crypto + val availableForWithdrawal = response?.balance?.availableForWithdrawal val paymentAccount = response?.paymentAccount val cardInfo = if (paymentAccount != null && card != null && fiatBalance != null && cryptoBalance != null) { CardInfo( @@ -190,6 +192,7 @@ internal class DefaultOnboardingRepository @Inject constructor( tokenContractAddress = cryptoBalance.tokenContractAddress, balance = cryptoBalance.balance, ), + availableForWithdrawal = availableForWithdrawal?.amount.orZero(), ) } else { null diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index 97af48e470..520c4c4328 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -115,6 +115,7 @@ sealed class PaymentAccountStatusValue { * @property depositAddress The address for deposits, if available. * @property fiatBalance The fiat balance details. * @property cryptoBalance The crypto balance details. + * @property availableForWithdrawal The crypto amount currently available for withdrawal/swap (excludes pending/locked funds). * @property cards The list of user's cards. */ @Serializable @@ -125,13 +126,14 @@ sealed class PaymentAccountStatusValue { val depositAddress: String?, val fiatBalance: FiatBalance, val cryptoBalance: CryptoBalance, + val availableForWithdrawal: SerializedBigDecimal, val cryptoCurrency: CryptoCurrency.Token, val cards: List, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, value = CryptoCurrencyStatus.Loaded( - amount = cryptoBalance.balance, + amount = availableForWithdrawal, fiatAmount = fiatBalance.availableBalance, fiatRate = BigDecimal.ONE, priceChange = BigDecimal.ZERO, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 67942f98f2..ed28273924 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -82,5 +82,6 @@ data class CustomerInfo( val isPinSet: Boolean, val fiatBalance: PaymentAccountStatusValue.FiatBalance, val cryptoBalance: PaymentAccountStatusValue.CryptoBalance, + val availableForWithdrawal: BigDecimal, ) } \ No newline at end of file From b206bf28c56aecb52e1784493086996e990b80bf Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 14 May 2026 11:08:33 +0000 Subject: [PATCH 188/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index a0cb218ffd..76d48569f9 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1508" +tangemBlockchainSdk = "releases-5.38-1512" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-608" +tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 9f6d8cb6b5dd1125b85a1f452c15c90627028ce1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 14 May 2026 16:50:03 +0500 Subject: [PATCH 189/206] Updated on 2026-08-14 --- .../TangemPayEditDisplayNameComponent.kt | 1 + .../tangempay/entity/TangemPayDetailsUM.kt | 1 + .../entity/TangemPayEditDisplayNameUM.kt | 1 + .../model/TangemPayEditDisplayNameModel.kt | 7 +++-- .../tangempay/ui/TangemPayCardDetailsBlock.kt | 17 ++++++----- .../tangempay/ui/TangemPayDetailsScreen.kt | 1 + .../ui/TangemPayEditDisplayNameScreen.kt | 28 ++++++------------- 7 files changed, 26 insertions(+), 30 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt index 571c294824..162a780e99 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayEditDisplayNameComponent.kt @@ -35,6 +35,7 @@ internal class TangemPayEditDisplayNameComponent( displayNameState = DisplayNameState.Editing( displayName = state.editingValue.text, editingValue = state.editingValue, + isSubmitEnabled = state.isDoneEnabled, onValueChanged = state.onValueChanged, onSubmit = state.onDoneClick, onDismiss = state.onDismiss, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 50fb7a2683..2a192392ec 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -47,6 +47,7 @@ internal sealed interface DisplayNameState { data class Editing( override val displayName: String, val editingValue: TextFieldValue, + val isSubmitEnabled: Boolean, val onValueChanged: (TextFieldValue) -> Unit, val onSubmit: () -> Unit, val onDismiss: () -> Unit, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt index 8d4c09a816..9885d4f337 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayEditDisplayNameUM.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.text.input.TextFieldValue internal data class TangemPayEditDisplayNameUM( val editingValue: TextFieldValue, val isLoading: Boolean, + val isDoneEnabled: Boolean, val onValueChanged: (TextFieldValue) -> Unit, val onDoneClick: () -> Unit, val onDismiss: () -> Unit, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt index 352d5d1373..b9f9fe24d8 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayEditDisplayNameModel.kt @@ -49,6 +49,7 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( selection = TextRange(originalDisplayName.length), ), isLoading = false, + isDoneEnabled = true, onValueChanged = ::onValueChanged, onDoneClick = ::onDoneClick, onDismiss = ::onDismiss, @@ -84,9 +85,9 @@ internal class TangemPayEditDisplayNameModel @Inject constructor( } private fun onValueChanged(value: TextFieldValue) { - if (value.text.length <= CardDisplayName.MAX_LENGTH) { - uiState.update { it.copy(editingValue = value) } - } + val displayName = CardDisplayName(value.text) + val isAvailableForConfirm = displayName.isRight() + uiState.update { it.copy(editingValue = value, isDoneEnabled = isAvailableForConfirm) } } private fun onDoneClick() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 0a58c2672a..18bf622b32 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -50,7 +50,6 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.TangemPayTestTags -import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.entity.DisplayNameState @@ -273,11 +272,7 @@ private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Mo BasicTextField( value = state.editingValue, - onValueChange = { newValue -> - if (newValue.text.length <= CardDisplayName.MAX_LENGTH) { - state.onValueChanged(newValue) - } - }, + onValueChange = state.onValueChanged, modifier = modifier .width(textWidthDp.coerceAtLeast(1.dp)) .focusRequester(focusRequester), @@ -285,7 +280,13 @@ private fun EditingCardDisplayName(state: DisplayNameState.Editing, modifier: Mo singleLine = true, cursorBrush = SolidColor(TangemTheme.colors.text.constantWhite), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { state.onSubmit() }), + keyboardActions = KeyboardActions( + onDone = if (state.isSubmitEnabled) { + { state.onSubmit() } + } else { + null + }, + ), decorationBox = { innerTextField -> Box { if (state.editingValue.text.isEmpty()) { @@ -481,6 +482,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide displayName = "Tangem", editingValue = TextFieldValue(text = "movet", selection = TextRange("movet".length)), onValueChanged = {}, + isSubmitEnabled = true, onSubmit = {}, onDismiss = {}, ), @@ -500,6 +502,7 @@ private class TangemPayCardDetailsUMProvider : CollectionPreviewParameterProvide displayName = "Tangem", editingValue = TextFieldValue(text = ""), onValueChanged = {}, + isSubmitEnabled = true, onSubmit = {}, onDismiss = {}, ), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index c6f426ec27..fabad9ef58 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -106,6 +106,7 @@ internal fun TangemPayDetailsScreen( TangemPayDetailsBalanceBlock( modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing16) + .padding(top = 12.dp) .fillMaxWidth(), state = state.balanceBlockState, isBalanceHidden = state.isBalanceHidden, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt index 6acfea29a1..18008536a7 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayEditDisplayNameScreen.kt @@ -1,25 +1,15 @@ package com.tangem.features.tangempay.ui import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.* import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton -import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent import com.tangem.features.tangempay.details.impl.R @@ -66,17 +56,15 @@ internal fun TangemPayEditDisplayNameScreen( Spacer(modifier = Modifier.weight(1f)) - NavigationPrimaryButton( + PrimaryButton( modifier = Modifier .imePadding() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .fillMaxWidth(), - primaryButton = NavigationButton( - textReference = resourceReference(R.string.common_done), - onClick = state.onDoneClick, - shouldShowProgress = state.isLoading, - isEnabled = !state.isLoading && state.editingValue.text.isNotBlank(), - ), + text = stringResourceSafe(R.string.common_done), + onClick = state.onDoneClick, + showProgress = state.isLoading, + enabled = !state.isLoading && state.isDoneEnabled, ) } } \ No newline at end of file From a35305794a88083e6989a65eff0d7f066d4f691f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 15 May 2026 01:00:29 +0500 Subject: [PATCH 190/206] Updated on 2026-08-14 --- .../src/main/java/com/tangem/feature/swap/model/SwapModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 6755e4209a..839bc9a964 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -739,7 +739,7 @@ internal class SwapModel @Inject constructor( shouldSanitize = false, ) } - }.getOrNull() + }.getOrNull() ?: fromSwapCurrencyStatus.status } dataState = dataState.copy(feePaidCryptoCurrency = feePaidCryptoCurrency) From 42fb506a0177184863e7e5c256b06426920f3435 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 13 May 2026 18:27:43 +0100 Subject: [PATCH 191/206] Updated on 2026-08-14 --- .../navigation/email/AndroidEmailSender.kt | 10 +- .../navigation/email/EmailMessageTruncator.kt | 35 +++++++ .../navigation/email/EmailSenderModule.kt | 5 +- .../email/EmailMessageTruncatorTest.kt | 98 +++++++++++++++++++ .../features/details/model/DetailsModel.kt | 38 ++++--- 5 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/core/navigation/email/EmailMessageTruncator.kt create mode 100644 app/src/test/kotlin/com/tangem/tap/core/navigation/email/EmailMessageTruncatorTest.kt diff --git a/app/src/main/java/com/tangem/tap/core/navigation/email/AndroidEmailSender.kt b/app/src/main/java/com/tangem/tap/core/navigation/email/AndroidEmailSender.kt index e0aea6d0e0..a8b0bd68d8 100644 --- a/app/src/main/java/com/tangem/tap/core/navigation/email/AndroidEmailSender.kt +++ b/app/src/main/java/com/tangem/tap/core/navigation/email/AndroidEmailSender.kt @@ -1,11 +1,11 @@ package com.tangem.tap.core.navigation.email import android.content.Intent -import android.net.Uri import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import androidx.core.net.toUri import com.tangem.core.navigation.email.EmailSender import com.tangem.tap.foregroundActivityObserver import com.tangem.utils.logging.TangemLogger @@ -15,7 +15,9 @@ import com.tangem.utils.logging.TangemLogger * [REDACTED_AUTHOR] */ -internal class AndroidEmailSender : EmailSender { +internal class AndroidEmailSender( + private val messageTruncator: EmailMessageTruncator, +) : EmailSender { override fun send(email: EmailSender.Email, onFail: ((Exception) -> Unit)?) { val activity = foregroundActivityObserver.foregroundActivity @@ -26,7 +28,7 @@ internal class AndroidEmailSender : EmailSender { } val originalIntent = createEmailShareIntent(activity, email) - val emailFilterIntent = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:")) + val emailFilterIntent = Intent(Intent.ACTION_SENDTO, "mailto:".toUri()) val packageManager = activity.packageManager val originalIntentResults = packageManager.queryIntentActivities(originalIntent, 0) @@ -59,7 +61,7 @@ internal class AndroidEmailSender : EmailSender { .setType("message/rfc822") .setEmailTo(arrayOf(email.address)) .setSubject(email.subject) - .setText(email.message) + .setText(messageTruncator.truncate(email.message)) email.attachment?.let { file -> builder.setStream( diff --git a/app/src/main/java/com/tangem/tap/core/navigation/email/EmailMessageTruncator.kt b/app/src/main/java/com/tangem/tap/core/navigation/email/EmailMessageTruncator.kt new file mode 100644 index 0000000000..f7a4bb281d --- /dev/null +++ b/app/src/main/java/com/tangem/tap/core/navigation/email/EmailMessageTruncator.kt @@ -0,0 +1,35 @@ +package com.tangem.tap.core.navigation.email + +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction + +/** + * Truncates an email body so the resulting Intent fits inside the per-process Binder buffer (1 MB). + * + * The chooser fans the Intent out to every installed email client (with extras duplicated per target), + * so the body must be kept well below the raw 1 MB ceiling. + */ +internal class EmailMessageTruncator { + + fun truncate(message: String): String { + val bytes = message.toByteArray(Charsets.UTF_8) + if (bytes.size <= MAX_MESSAGE_BYTES) return message + + val suffix = TRUNCATION_SUFFIX_TEMPLATE.format(bytes.size) + val suffixBytes = suffix.toByteArray(Charsets.UTF_8).size + val cutSize = MAX_MESSAGE_BYTES - suffixBytes + + // Drop a partial UTF-8 sequence at the cut boundary rather than replacing it with U+FFFD + // (which is 3 bytes in UTF-8 and would push the result over the cap). + val decoder = Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.IGNORE) + val head = decoder.decode(ByteBuffer.wrap(bytes, 0, cutSize)).toString() + return head + suffix + } + + private companion object { + // Chooser duplicates EXTRA_TEXT once per target email app (EXTRA_INITIAL_INTENTS), + // so parcel ≈ N × body. 20 KB clears the 1 MB Binder limit for up to ~30 mail clients. + const val MAX_MESSAGE_BYTES = 20_000 + const val TRUNCATION_SUFFIX_TEMPLATE = "\n\n…[truncated, original %d bytes]" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/core/navigation/email/EmailSenderModule.kt b/app/src/main/java/com/tangem/tap/di/core/navigation/email/EmailSenderModule.kt index b986cfe46d..0aacfd2742 100644 --- a/app/src/main/java/com/tangem/tap/di/core/navigation/email/EmailSenderModule.kt +++ b/app/src/main/java/com/tangem/tap/di/core/navigation/email/EmailSenderModule.kt @@ -2,6 +2,7 @@ package com.tangem.tap.di.core.navigation.email import com.tangem.core.navigation.email.EmailSender import com.tangem.tap.core.navigation.email.AndroidEmailSender +import com.tangem.tap.core.navigation.email.EmailMessageTruncator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,5 +15,7 @@ internal object EmailSenderModule { @Provides @Singleton - fun provideEmailSender(): EmailSender = AndroidEmailSender() + fun provideEmailSender(): EmailSender = AndroidEmailSender( + messageTruncator = EmailMessageTruncator(), + ) } \ No newline at end of file diff --git a/app/src/test/kotlin/com/tangem/tap/core/navigation/email/EmailMessageTruncatorTest.kt b/app/src/test/kotlin/com/tangem/tap/core/navigation/email/EmailMessageTruncatorTest.kt new file mode 100644 index 0000000000..07cd8a2d33 --- /dev/null +++ b/app/src/test/kotlin/com/tangem/tap/core/navigation/email/EmailMessageTruncatorTest.kt @@ -0,0 +1,98 @@ +package com.tangem.tap.core.navigation.email + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class EmailMessageTruncatorTest { + + private val truncator = EmailMessageTruncator() + + @Test + fun `empty message returned as-is`() { + val result = truncator.truncate("") + + assertThat(result).isEqualTo("") + } + + @Test + fun `message under cap returned unchanged`() { + val message = "small message" + + val result = truncator.truncate(message) + + assertThat(result).isEqualTo(message) + } + + @Test + fun `message exactly at cap returned unchanged`() { + val message = "a".repeat(MAX_MESSAGE_BYTES) + + val result = truncator.truncate(message) + + assertThat(result).isEqualTo(message) + } + + @Test + fun `message over cap is truncated to fit within cap in bytes`() { + val message = "a".repeat(MAX_MESSAGE_BYTES + 1_000) + + val result = truncator.truncate(message) + + assertThat(result.toByteArray(Charsets.UTF_8).size).isAtMost(MAX_MESSAGE_BYTES) + } + + @Test + fun `truncated message preserves the head of the original`() { + val head = "HEAD_MARKER_" + "x".repeat(100) + val tail = "y".repeat(MAX_MESSAGE_BYTES) + val message = head + tail + + val result = truncator.truncate(message) + + assertThat(result).startsWith(head) + } + + @Test + fun `truncated message ends with the truncation suffix`() { + val message = "a".repeat(MAX_MESSAGE_BYTES + 1_000) + + val result = truncator.truncate(message) + + assertThat(result).contains("[truncated, original ${message.length} bytes]") + } + + @Test + fun `truncation suffix reports original byte length not character length`() { + // Each emoji is 4 bytes in UTF-8. + val emoji = "😀" // 😀 + val message = emoji.repeat(MAX_MESSAGE_BYTES / 4 + 10) + val originalBytes = message.toByteArray(Charsets.UTF_8).size + + val result = truncator.truncate(message) + + assertThat(result).contains("[truncated, original $originalBytes bytes]") + } + + @Test + fun `multi-byte UTF-8 boundary stays within cap and produces valid output`() { + // Build a message where the cap falls inside a multi-byte char. + val emoji = "😀" // 😀, 4 bytes in UTF-8 + val message = emoji.repeat(MAX_MESSAGE_BYTES) // Way over cap. + + val result = truncator.truncate(message) + + // Partial trailing char is dropped (not replaced with U+FFFD which is 3 bytes and would + // push the result over the cap), so the result must stay within the cap and survive a + // UTF-8 round-trip. + val roundTripped = String(result.toByteArray(Charsets.UTF_8), Charsets.UTF_8) + assertThat(roundTripped).isEqualTo(result) + assertThat(result.toByteArray(Charsets.UTF_8).size).isAtMost(MAX_MESSAGE_BYTES) + } + + private companion object { + // Mirror the constant inside EmailMessageTruncator. Keep in sync if it changes there. + const val MAX_MESSAGE_BYTES = 20_000 + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index 840d7776a1..7127fd8a3b 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -124,18 +124,29 @@ internal class DetailsModel @Inject constructor( val metaInfo = getWalletMetaInfoUseCase(selectedUserWallet.walletId).getOrNull() ?: return@launch val visaCustomerId = getTangemPayCustomerIdUseCase(selectedUserWallet.walletId).getOrNull() + val coldVisaPredicate = { userWallet: UserWallet -> + userWallet is UserWallet.Cold && userWallet.scanResponse.card.isVisa && !visaCustomerId.isNullOrEmpty() + } + val hotWalletOrNotVisaPredicate = { userWallet: UserWallet -> + userWallet !is UserWallet.Cold || userWallet.scanResponse.card.isVisa.not() + } val feedbackType = when { - userWallets.all { - it is UserWallet.Cold && it.scanResponse.card.isVisa && !visaCustomerId.isNullOrEmpty() - } -> + userWallets.all(coldVisaPredicate) -> { FeedbackEmailType.Visa.DirectUserRequest( walletMetaInfo = metaInfo, customerId = requireNotNull(visaCustomerId), ) - userWallets.all { it !is UserWallet.Cold || it.scanResponse.card.isVisa.not() } -> - FeedbackEmailType.DirectUserRequest(metaInfo) + } + userWallets.all(hotWalletOrNotVisaPredicate) -> { + FeedbackEmailType.DirectUserRequest( + walletMetaInfo = metaInfo, + ) + } else -> { - showFeedbackEmailTypeOptionBS(selectedWalletMetaInfo = metaInfo, visaCustomerId = visaCustomerId) + showFeedbackEmailTypeOptionBS( + selectedWalletMetaInfo = metaInfo, + visaCustomerId = visaCustomerId, + ) return@launch } } @@ -161,8 +172,9 @@ internal class DetailsModel @Inject constructor( onDismissRequest = { state.update { current.copy( - selectFeedbackEmailTypeBSConfig = - current.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + selectFeedbackEmailTypeBSConfig = current.selectFeedbackEmailTypeBSConfig.copy( + isShown = false, + ), ) } }, @@ -174,10 +186,12 @@ internal class DetailsModel @Inject constructor( visaCustomerId = visaCustomerId, ) - state.update { - it.copy( - selectFeedbackEmailTypeBSConfig = - it.selectFeedbackEmailTypeBSConfig.copy(isShown = false), + state.update { details -> + val hiddenConfig = details.selectFeedbackEmailTypeBSConfig.copy( + isShown = false, + ) + details.copy( + selectFeedbackEmailTypeBSConfig = hiddenConfig, ) } }, From 8098476fde0942dbed330fea71a2f36ac17ff5d7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 15 May 2026 05:44:29 -0700 Subject: [PATCH 192/206] Updated on 2026-08-14 --- .../tangem/data/pay/di/TangemPayDataModule.kt | 24 +++ .../DefaultReissueCardRepository.kt | 28 +-- .../DefaultTangemPayCardDetailsRepository.kt | 179 ++++++------------ ...MockAwareTangemPayCardDetailsRepository.kt | 13 +- domain/visa/build.gradle.kts | 12 ++ .../tangem/domain/pay/model/OrderStatus.kt | 5 +- ...ssueOrderInfo.kt => TangemPayOrderInfo.kt} | 2 +- .../TangemPayCardDetailsRepository.kt | 11 +- .../TangemPayReissueCardRepository.kt | 6 +- .../usecase/ChangeCardFrozenStateUseCase.kt | 45 +++++ .../StartTangemPayOrderPollingUseCase.kt | 35 ++++ .../ChangeCardFrozenStateUseCaseTest.kt | 107 +++++++++++ .../StartTangemPayOrderPollingUseCaseTest.kt | 119 ++++++++++++ .../TangemPayReissueCardComponent.kt | 4 +- .../tangempay/model/TangemPayCardPageModel.kt | 49 ++--- 15 files changed, 459 insertions(+), 180 deletions(-) rename domain/visa/src/main/kotlin/com/tangem/domain/pay/model/{TangemPayReissueOrderInfo.kt => TangemPayOrderInfo.kt} (71%) create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt create mode 100644 domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt create mode 100644 domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index ade825296a..aea6884b96 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -26,9 +26,11 @@ import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusProducer import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.repository.* +import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase +import com.tangem.domain.pay.usecase.StartTangemPayOrderPollingUseCase import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase @@ -172,5 +174,27 @@ internal interface TangemPayDataModule { ): ProduceTangemPayInitialDataUseCase { return ProduceTangemPayInitialDataUseCase(repository = repository) } + + @Provides + fun provideChangeCardFrozenStateUseCase( + cardDetailsRepository: TangemPayCardDetailsRepository, + startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + appCoroutineScope: AppCoroutineScope, + ): ChangeCardFrozenStateUseCase { + return ChangeCardFrozenStateUseCase( + cardDetailsRepository = cardDetailsRepository, + startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, + appCoroutineScope = appCoroutineScope, + ) + } + + @Provides + @Singleton + fun provideStartTangemPayPollingUseCase( + cardDetailsRepository: TangemPayCardDetailsRepository, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + ): StartTangemPayOrderPollingUseCase { + return StartTangemPayOrderPollingUseCase(cardDetailsRepository, paymentAccountStatusFetcher) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt index 0e85fa2f63..a2295443b3 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultReissueCardRepository.kt @@ -7,12 +7,11 @@ import com.tangem.core.error.UniversalError import com.tangem.data.pay.util.OrderStatusConverter import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.ReissueCardRequest -import com.tangem.datasource.api.pay.models.response.OrderResponse import com.tangem.datasource.local.visa.TangemPayReissueCardStore import com.tangem.domain.models.pay.TangemPayReissueCardFee import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.domain.visa.error.VisaApiError import com.tangem.utils.coroutines.runSuspendCatching @@ -22,6 +21,7 @@ internal class DefaultReissueCardRepository @Inject constructor( private val tangemPayApi: TangemPayApi, private val requestHelper: TangemPayRequestPerformer, private val tangemPayReissueCardStore: TangemPayReissueCardStore, + private val cardDetailsRepository: TangemPayCardDetailsRepository, ) : TangemPayReissueCardRepository { override suspend fun getReissueCardFee(userWalletId: UserWalletId): Either = @@ -53,14 +53,14 @@ internal class DefaultReissueCardRepository @Inject constructor( override suspend fun reissueCard( userWalletId: UserWalletId, cardId: String, - ): Either = either { + ): Either = either { val response = requestHelper.performRequest(userWalletId) { authHeader -> tangemPayApi.reissueCard( authHeader = authHeader, body = ReissueCardRequest(cardId = cardId), ) }.bind() - TangemPayReissueOrderInfo( + TangemPayOrderInfo( orderId = response.result.orderId, orderStatus = OrderStatusConverter.convert(response.result.status), ) @@ -77,28 +77,14 @@ internal class DefaultReissueCardRepository @Inject constructor( override suspend fun getReissueOrderInfo( userWalletId: UserWalletId, cardId: String, - ): Either = either { + ): Either = either { val orderId = runSuspendCatching { tangemPayReissueCardStore.getOrderId(cardId) }.getOrNull() if (orderId == null) { return null.right() } - val order = requestHelper.performRequest(userWalletId) { authHeader -> - tangemPayApi.getOrder(authHeader, orderId) - }.bind() - - val result = order.result ?: raise(VisaApiError.Unspecified) - - TangemPayReissueOrderInfo( - orderId = result.id, - orderStatus = when (result.status) { - OrderResponse.Result.Status.NEW -> OrderStatus.NEW - OrderResponse.Result.Status.PROCESSING -> OrderStatus.PROCESSING - OrderResponse.Result.Status.COMPLETED -> OrderStatus.COMPLETED - OrderResponse.Result.Status.CANCELED -> OrderStatus.CANCELED - }, - ) + cardDetailsRepository.getOrderInfo(userWalletId, orderId).bind() } private companion object { diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt index 97657f4c0d..cb621a3e3d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultTangemPayCardDetailsRepository.kt @@ -3,6 +3,7 @@ package com.tangem.data.pay.repository import arrow.core.Either import arrow.core.left import arrow.core.raise.catch +import arrow.core.raise.either import arrow.core.right import com.tangem.core.error.UniversalError import com.tangem.data.pay.util.RainCryptoUtil @@ -22,22 +23,17 @@ import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.TangemPayCardFrozenState -import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.logging.TangemLogger -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds private const val TAG = "TangemPay: CardDetailsRepository" @@ -51,12 +47,8 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( private val storage: TangemPayStorage, private val cardFrozenStateStore: TangemPayCardFrozenStateStore, private val errorConverter: TangemPayErrorConverter, - private val pollingScope: AppCoroutineScope, ) : TangemPayCardDetailsRepository { - private val pollingJobs = mutableMapOf() - private val storePollingMutex = Mutex() - override suspend fun getCardBalance(userWalletId: UserWalletId): Either { return catch( block = { @@ -201,123 +193,43 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( override suspend fun freezeCard( userWalletId: UserWalletId, cardId: String, - ): Either { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) - return requestHelper.performRequest(userWalletId) { + ): Either = either { + val response = requestHelper.performRequest(userWalletId) { tangemPayApi.freezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId)) - }.onLeft { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Unfrozen) - }.map { response -> - val state = when (response.result?.status) { - FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Frozen - FreezeUnfreezeCardResponse.Status.NEW, - FreezeUnfreezeCardResponse.Status.PROCESSING, - -> TangemPayCardFrozenState.Pending - FreezeUnfreezeCardResponse.Status.CANCELED, - null, - -> TangemPayCardFrozenState.Unfrozen - } - if (state == TangemPayCardFrozenState.Pending) { - startOrderIdPolling( - userWalletId = userWalletId, - cardId = cardId, - orderId = response.result?.orderId, - isFreeze = true, - ) - } - cardFrozenStateStore.store(cardId, state) + }.bind() - state - } + val result = response.result ?: raise(VisaApiError.Unspecified) + + TangemPayOrderInfo( + orderId = result.orderId, + orderStatus = when (result.status) { + FreezeUnfreezeCardResponse.Status.NEW -> OrderStatus.NEW + FreezeUnfreezeCardResponse.Status.PROCESSING -> OrderStatus.PROCESSING + FreezeUnfreezeCardResponse.Status.COMPLETED -> OrderStatus.COMPLETED + FreezeUnfreezeCardResponse.Status.CANCELED -> OrderStatus.CANCELED + }, + ) } override suspend fun unfreezeCard( userWalletId: UserWalletId, cardId: String, - ): Either { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Pending) - return requestHelper.performRequest(userWalletId) { + ): Either = either { + val response = requestHelper.performRequest(userWalletId) { tangemPayApi.unfreezeCard(authHeader = it, body = FreezeUnfreezeCardRequest(cardId = cardId)) - }.onLeft { - cardFrozenStateStore.store(cardId, TangemPayCardFrozenState.Frozen) - }.map { response -> - val state = when (response.result?.status) { - FreezeUnfreezeCardResponse.Status.COMPLETED -> TangemPayCardFrozenState.Unfrozen - FreezeUnfreezeCardResponse.Status.NEW, - FreezeUnfreezeCardResponse.Status.PROCESSING, - -> TangemPayCardFrozenState.Pending - FreezeUnfreezeCardResponse.Status.CANCELED, - null, - -> TangemPayCardFrozenState.Frozen - } - if (state == TangemPayCardFrozenState.Pending) { - startOrderIdPolling( - userWalletId = userWalletId, - cardId = cardId, - orderId = response.result?.orderId, - isFreeze = false, - ) - } - cardFrozenStateStore.store(cardId, state) + }.bind() - state - } - } + val result = response.result ?: raise(VisaApiError.Unspecified) - private suspend fun startOrderIdPolling( - userWalletId: UserWalletId, - cardId: String, - orderId: String?, - isFreeze: Boolean, - ) { - if (orderId.isNullOrEmpty()) return - storePollingMutex.withLock { - if (pollingJobs.containsKey(orderId)) return - val pollingJob = pollingScope.launch { - try { - var retryCount = 0 - while (isActive && pollingJobs.containsKey(orderId)) { - delay(duration = 5.seconds) - - val orderStatus = requestHelper.performRequest(userWalletId) { authHeader -> - tangemPayApi.getOrder(authHeader, orderId) - } - - orderStatus.onRight { response -> - val status = response.result?.status - if (status == Status.COMPLETED || status == Status.CANCELED) { - // Remove from jobs - pollingJobs.remove(key = orderId) - - // Final card state - val finalState = when { - status == Status.COMPLETED && isFreeze - -> TangemPayCardFrozenState.Frozen - status == Status.COMPLETED && !isFreeze - -> TangemPayCardFrozenState.Unfrozen - else -> return@launch - } - - cardFrozenStateStore.store(cardId, finalState) - } - }.onLeft { error -> - TangemLogger.e("error ${error.errorCode}") - // stop retrying after 3 errors - if (retryCount > MAX_POLLING_RETRIES) { - pollingJobs.remove(key = orderId) - } - } - retryCount++ - } - } catch (e: Exception) { - TangemLogger.e("Error", e) - storePollingMutex.withLock { - pollingJobs.remove(orderId) - } - } - } - pollingJobs[orderId] = pollingJob - } + TangemPayOrderInfo( + orderId = result.orderId, + orderStatus = when (result.status) { + FreezeUnfreezeCardResponse.Status.NEW -> OrderStatus.NEW + FreezeUnfreezeCardResponse.Status.PROCESSING -> OrderStatus.PROCESSING + FreezeUnfreezeCardResponse.Status.COMPLETED -> OrderStatus.COMPLETED + FreezeUnfreezeCardResponse.Status.CANCELED -> OrderStatus.CANCELED + }, + ) } override suspend fun updateCardDisplayName( @@ -376,6 +288,31 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( return cardFrozenStateStore.getSyncOrNull(cardId) } + override suspend fun setCardFrozenState(cardId: String, state: TangemPayCardFrozenState) { + cardFrozenStateStore.store(cardId, state) + } + + override suspend fun getOrderInfo( + userWalletId: UserWalletId, + orderId: String, + ): Either = either { + val order = requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getOrder(authHeader, orderId) + }.bind() + + val result = order.result ?: raise(VisaApiError.Unspecified) + + TangemPayOrderInfo( + orderId = result.id, + orderStatus = when (result.status) { + Status.NEW -> OrderStatus.NEW + Status.PROCESSING -> OrderStatus.PROCESSING + Status.COMPLETED -> OrderStatus.COMPLETED + Status.CANCELED -> OrderStatus.CANCELED + }, + ) + } + private suspend fun getPublicKeyBase64(): String { val env = apiConfigsManager.getEnvironmentConfig(ApiConfig.ID.TangemPay).environment return when (env) { @@ -395,8 +332,4 @@ internal class DefaultTangemPayCardDetailsRepository @Inject constructor( TangemLogger.withTag(TAG).e("Error", throwable) return errorConverter.convert(throwable).left() } - - private companion object { - const val MAX_POLLING_RETRIES = 3 - } } \ No newline at end of file diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt index fadd88988c..17a322804b 100644 --- a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareTangemPayCardDetailsRepository.kt @@ -11,6 +11,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.coroutines.flow.Flow @@ -65,12 +66,12 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor( override suspend fun freezeCard( userWalletId: UserWalletId, cardId: String, - ): Either = real.freezeCard(userWalletId, cardId) + ): Either = real.freezeCard(userWalletId, cardId) override suspend fun unfreezeCard( userWalletId: UserWalletId, cardId: String, - ): Either = real.unfreezeCard(userWalletId, cardId) + ): Either = real.unfreezeCard(userWalletId, cardId) override fun cardFrozenState(cardId: String): Flow = real.cardFrozenState(cardId) @@ -78,6 +79,14 @@ internal class MockAwareTangemPayCardDetailsRepository @Inject constructor( override suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? = real.cardFrozenStateSync(cardId) + override suspend fun setCardFrozenState(cardId: String, state: TangemPayCardFrozenState) = + real.setCardFrozenState(cardId, state) + + override suspend fun getOrderInfo( + userWalletId: UserWalletId, + orderId: String, + ): Either = real.getOrderInfo(userWalletId, orderId) + override suspend fun updateCardDisplayName( cardId: String, userWalletId: UserWalletId, diff --git a/domain/visa/build.gradle.kts b/domain/visa/build.gradle.kts index ec2d1affc7..47c4e35082 100644 --- a/domain/visa/build.gradle.kts +++ b/domain/visa/build.gradle.kts @@ -9,6 +9,10 @@ android { namespace = "com.tangem.domain.visa" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { /** Project - Core */ api(projects.core.pagination) @@ -34,4 +38,12 @@ dependencies { implementation(deps.moshi) implementation(deps.moshi.kotlin) ksp(deps.moshi.kotlin.codegen) + + /** Tests */ + testImplementation(deps.test.junit5) + testRuntimeOnly(deps.test.junit5.engine) + testImplementation(deps.test.mockk) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(projects.common.test) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index fd3d717107..035572167a 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -5,4 +5,7 @@ enum class OrderStatus { PROCESSING, COMPLETED, CANCELED, -} \ No newline at end of file +} + +val OrderStatus.isFinalStatus + get() = this == OrderStatus.COMPLETED || this == OrderStatus.CANCELED \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt similarity index 71% rename from domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt rename to domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt index 45945f7204..eebc510bd5 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayReissueOrderInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/TangemPayOrderInfo.kt @@ -1,6 +1,6 @@ package com.tangem.domain.pay.model -data class TangemPayReissueOrderInfo( +data class TangemPayOrderInfo( val orderId: String, val orderStatus: OrderStatus, ) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt index 6778223272..b3eed00dba 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayCardDetailsRepository.kt @@ -7,6 +7,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.SetPinResult import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayCardDetails +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.visa.model.TangemPayCardFrozenState import kotlinx.coroutines.flow.Flow @@ -24,14 +25,12 @@ interface TangemPayCardDetailsRepository { suspend fun setAddToWalletAsDone(userWalletId: UserWalletId): Either - suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either - suspend fun unfreezeCard( - userWalletId: UserWalletId, - cardId: String, - ): Either + suspend fun freezeCard(userWalletId: UserWalletId, cardId: String): Either + suspend fun unfreezeCard(userWalletId: UserWalletId, cardId: String): Either fun cardFrozenState(cardId: String): Flow suspend fun cardFrozenStateSync(cardId: String): TangemPayCardFrozenState? + suspend fun setCardFrozenState(cardId: String, state: TangemPayCardFrozenState) suspend fun updateCardDisplayName( cardId: String, @@ -44,4 +43,6 @@ interface TangemPayCardDetailsRepository { userWalletId: UserWalletId, limit: String, ): Either + + suspend fun getOrderInfo(userWalletId: UserWalletId, orderId: String): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt index 3f756df4ee..1487f57359 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/TangemPayReissueCardRepository.kt @@ -4,19 +4,19 @@ import arrow.core.Either import com.tangem.core.error.UniversalError import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.pay.TangemPayReissueCardFee -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.visa.error.VisaApiError interface TangemPayReissueCardRepository { suspend fun getReissueCardFee(userWalletId: UserWalletId): Either - suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either + suspend fun reissueCard(userWalletId: UserWalletId, cardId: String): Either suspend fun storeReissueOrderId(cardId: String, orderId: String): Either suspend fun getReissueOrderInfo( userWalletId: UserWalletId, cardId: String, - ): Either + ): Either } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt new file mode 100644 index 0000000000..3aeb3e865f --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCase.kt @@ -0,0 +1,45 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.core.error.UniversalError +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import com.tangem.utils.coroutines.AppCoroutineScope +import kotlinx.coroutines.async + +class ChangeCardFrozenStateUseCase( + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + private val appCoroutineScope: AppCoroutineScope, +) { + suspend operator fun invoke( + userWalletId: UserWalletId, + cardId: String, + isFreezing: Boolean, + ): Either { + val successState = if (isFreezing) TangemPayCardFrozenState.Frozen else TangemPayCardFrozenState.Unfrozen + val failState = if (isFreezing) TangemPayCardFrozenState.Unfrozen else TangemPayCardFrozenState.Frozen + return either { + cardDetailsRepository.setCardFrozenState(cardId, TangemPayCardFrozenState.Pending) + + val order = if (isFreezing) { + cardDetailsRepository.freezeCard(userWalletId, cardId).bind() + } else { + cardDetailsRepository.unfreezeCard(userWalletId, cardId).bind() + } + + val isCompleted = appCoroutineScope.async { + val isCompleted = startTangemPayOrderPollingUseCase(order, userWalletId) + cardDetailsRepository.setCardFrozenState(cardId, if (isCompleted) successState else failState) + isCompleted + }.await() + + if (!isCompleted) raise(VisaApiError.Unspecified) + }.onLeft { + cardDetailsRepository.setCardFrozenState(cardId, failState) + } + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt new file mode 100644 index 0000000000..50ea3fdfd9 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt @@ -0,0 +1,35 @@ +package com.tangem.domain.pay.usecase + +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.model.isFinalStatus +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import kotlinx.coroutines.delay + +class StartTangemPayOrderPollingUseCase( + private val cardDetailsRepository: TangemPayCardDetailsRepository, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, +) { + suspend operator fun invoke(order: TangemPayOrderInfo, userWalletId: UserWalletId): Boolean { + while (true) { + val newOrder = if (order.orderStatus.isFinalStatus) { + order + } else { + cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull() + } + + if (newOrder != null && newOrder.orderStatus.isFinalStatus) { + paymentAccountStatusFetcher.invoke(userWalletId) + return newOrder.orderStatus == OrderStatus.COMPLETED + } + + delay(POLLING_DELAY) + } + } + + companion object { + private const val POLLING_DELAY = 3000L + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt new file mode 100644 index 0000000000..67fd67b1a5 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/ChangeCardFrozenStateUseCaseTest.kt @@ -0,0 +1,107 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.TestAppCoroutineScope +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.domain.visa.model.TangemPayCardFrozenState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class ChangeCardFrozenStateUseCaseTest { + + private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxUnitFun = true) + private val startPollingUseCase: StartTangemPayOrderPollingUseCase = mockk() + + @Test + fun `GIVEN freezeCard fails WHEN invoke with isFreezing=true THEN sets Pending then Unfrozen and returns Left`() = + runTest { + val useCase = createUseCase() + coEvery { + cardDetailsRepository.freezeCard(USER_WALLET_ID, CARD_ID) + } returns VisaApiError.Unspecified.left() + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = true) + + assertThat(result.isLeft()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Unfrozen) + } + coVerify(exactly = 0) { startPollingUseCase(any(), any()) } + } + + @Test + fun `GIVEN unfreezeCard fails WHEN invoke with isFreezing=false THEN sets Pending then Frozen and returns Left`() = + runTest { + val useCase = createUseCase() + coEvery { + cardDetailsRepository.unfreezeCard(USER_WALLET_ID, CARD_ID) + } returns VisaApiError.Unspecified.left() + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = false) + + assertThat(result.isLeft()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Frozen) + } + coVerify(exactly = 0) { startPollingUseCase(any(), any()) } + } + + @Test + fun `GIVEN freeze succeeds and order COMPLETED WHEN invoke with isFreezing=true THEN sets Frozen and returns Right`() = + runTest { + val useCase = createUseCase() + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { cardDetailsRepository.freezeCard(USER_WALLET_ID, CARD_ID) } returns order.right() + coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = true) + + assertThat(result.isRight()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Frozen) + } + } + + @Test + fun `GIVEN unfreeze succeeds and order COMPLETED WHEN invoke with isFreezing=false THEN sets Unfrozen and returns Right`() = + runTest { + val useCase = createUseCase() + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { cardDetailsRepository.unfreezeCard(USER_WALLET_ID, CARD_ID) } returns order.right() + coEvery { startPollingUseCase(order, USER_WALLET_ID) } returns true + + val result = useCase(USER_WALLET_ID, CARD_ID, isFreezing = false) + + assertThat(result.isRight()).isTrue() + coVerifyOrder { + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Pending) + cardDetailsRepository.setCardFrozenState(CARD_ID, TangemPayCardFrozenState.Unfrozen) + } + } + + private fun TestScope.createUseCase() = ChangeCardFrozenStateUseCase( + cardDetailsRepository = cardDetailsRepository, + startTangemPayOrderPollingUseCase = startPollingUseCase, + appCoroutineScope = TestAppCoroutineScope(this), + ) + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + const val CARD_ID = "card-test-id" + const val ORDER_ID = "order-test-1" + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt new file mode 100644 index 0000000000..86ab6a5bf2 --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt @@ -0,0 +1,119 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.model.TangemPayOrderInfo +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.domain.visa.error.VisaApiError +import io.mockk.* +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +internal class StartTangemPayOrderPollingUseCaseTest { + + private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk() + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher = mockk() + + private val useCase = StartTangemPayOrderPollingUseCase( + cardDetailsRepository = cardDetailsRepository, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + ) + + @Test + fun `GIVEN order already COMPLETED WHEN invoke THEN returns true without polling`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + coVerify(exactly = 0) { cardDetailsRepository.getOrderInfo(any(), any()) } + } + + @Test + fun `GIVEN order already CANCELED WHEN invoke THEN returns false without polling`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.CANCELED) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isFalse() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + coVerify(exactly = 0) { cardDetailsRepository.getOrderInfo(any(), any()) } + } + + @Test + fun `GIVEN processing order WHEN poll returns COMPLETED THEN returns true and fetches status`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returns TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right() + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 1) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + @Test + fun `GIVEN processing order WHEN poll returns CANCELED THEN returns false and fetches status`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returns TangemPayOrderInfo(ORDER_ID, OrderStatus.CANCELED).right() + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isFalse() + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + @Test + fun `GIVEN new order WHEN getOrderInfo fails once then returns COMPLETED THEN returns true after two polls`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.NEW) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returnsMany listOf( + VisaApiError.Unspecified.left(), + TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right(), + ) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 2) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } + } + + @Test + fun `GIVEN processing order WHEN multiple non-final polls then COMPLETED THEN returns true after all polls`() = runTest { + val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING) + coEvery { + cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) + } returnsMany listOf( + TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING).right(), + TangemPayOrderInfo(ORDER_ID, OrderStatus.NEW).right(), + TangemPayOrderInfo(ORDER_ID, OrderStatus.COMPLETED).right(), + ) + coEvery { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } returns Unit.right() + + val result = useCase(order, USER_WALLET_ID) + + assertThat(result).isTrue() + coVerify(exactly = 3) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } + coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) } + } + + private companion object { + val USER_WALLET_ID = UserWalletId("aabbcc112233") + const val ORDER_ID = "order-test-1" + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt index 13f1c7c061..918ad5d9af 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt @@ -7,7 +7,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.features.tangempay.model.TangemPayReissueCardModel import com.tangem.features.tangempay.ui.TangemPayReissueCardContent @@ -34,7 +34,7 @@ internal class TangemPayReissueCardComponent( } internal interface ReissueCardListener { - fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) + fun onReissueOrderCreate(order: TangemPayOrderInfo) fun onDismissReissueCard() fun onClickAddFunds() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index f5c7e0a5e5..c0f9d07ad2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -19,6 +19,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.format.bigdecimal.optionalDecimals import com.tangem.core.ui.message.SnackbarMessage +import com.tangem.core.ui.test.TangemPayTestTags import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue @@ -28,12 +29,12 @@ import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.pay.model.TangemPayReissueOrderInfo +import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents -import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.features.tangempay.components.AddFundsListener import com.tangem.features.tangempay.components.ReissueCardListener import com.tangem.features.tangempay.components.TangemPayCardPageComponent @@ -63,12 +64,14 @@ internal class TangemPayCardPageModel @Inject constructor( private val cardDetailsRepository: TangemPayCardDetailsRepository, private val uiMessageSender: UiMessageSender, private val reissueCardRepository: TangemPayReissueCardRepository, + private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase, ) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener { private val params: TangemPayCardPageComponent.Params = paramsContainer.require() private val addToWalletBannerJobHolder = JobHolder() private val addFundsJobHolder = JobHolder() + private val frozenStateJobHolder = JobHolder() val uiState: StateFlow field = MutableStateFlow( @@ -119,12 +122,18 @@ internal class TangemPayCardPageModel @Inject constructor( TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_change_pin), onSettingClick = { onClickChangePIN(card.hasPinCode) }, - testTag = com.tangem.core.ui.test.TangemPayTestTags.CHANGE_PIN_ROW, + testTag = TangemPayTestTags.CHANGE_PIN_ROW, ), TangemPayCardPageSetting( - title = TextReference.Res(R.string.tangempay_card_details_freeze_card), + title = TextReference.Res( + if (card.isFrozen) { + R.string.tangempay_card_details_unfreeze_card + } else { + R.string.tangempay_card_details_freeze_card + }, + ), onSettingClick = { onClickFreezeOrUnfreezeCard(card.isFrozen) }, - testTag = com.tangem.core.ui.test.TangemPayTestTags.FREEZE_CARD_ROW, + testTag = TangemPayTestTags.FREEZE_CARD_ROW, ), TangemPayCardPageSetting( title = TextReference.Res(R.string.tangempay_card_details_reissue_card), @@ -152,6 +161,8 @@ internal class TangemPayCardPageModel @Inject constructor( } private fun onClickFreezeOrUnfreezeCard(isFrozen: Boolean) { + if (frozenStateJobHolder.isActive) return + val message = if (isFrozen) { TangemPayMessagesFactory.createUnfreezeCardMessage(onUnfreezeClicked = ::unfreezeCard) } else { @@ -165,7 +176,7 @@ internal class TangemPayCardPageModel @Inject constructor( bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard) } - override fun onReissueOrderCreate(order: TangemPayReissueOrderInfo) { + override fun onReissueOrderCreate(order: TangemPayOrderInfo) { bottomSheetNavigation.dismiss() onReissueOrderStatusReceived(order.orderStatus) if (order.orderStatus != OrderStatus.CANCELED) { @@ -238,40 +249,34 @@ internal class TangemPayCardPageModel @Inject constructor( private fun freezeCard() { modelScope.launch { - cardDetailsRepository.freezeCard( + changeCardFrozenStateUseCase( userWalletId = params.userWalletId, cardId = params.config.cardId, + isFreezing = true, ).onLeft { val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) uiMessageSender.send(message) - }.onRight { state -> - val message = if (state == TangemPayCardFrozenState.Frozen) { - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)) - } else { - SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_failed)) - } + }.onRight { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_freeze_card_success)) uiMessageSender.send(message) } - } + }.saveIn(frozenStateJobHolder) } private fun unfreezeCard() { modelScope.launch { - cardDetailsRepository.unfreezeCard( + changeCardFrozenStateUseCase( userWalletId = params.userWalletId, cardId = params.config.cardId, + isFreezing = false, ).onLeft { val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) uiMessageSender.send(message) - }.onRight { state -> - val message = if (state == TangemPayCardFrozenState.Unfrozen) { - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)) - } else { - SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_failed)) - } + }.onRight { + val message = SnackbarMessage(resourceReference(R.string.tangem_pay_unfreeze_card_success)) uiMessageSender.send(message) } - } + }.saveIn(frozenStateJobHolder) } private fun fetchAddToWalletBanner() { From e257706f0f57a53287562c2510f004f9bed66eb8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 18 May 2026 14:03:33 +0500 Subject: [PATCH 193/206] Updated on 2026-08-14 --- .../tangem/feature/swap/model/SwapModel.kt | 11 +---- .../swap/model/SwapNotificationsFactory.kt | 45 ++++++++++++++----- .../tangem/feature/swap/models/UiActions.kt | 1 - .../swap/models/states/SwapNotificationUM.kt | 11 +++-- .../tangem/feature/swap/ui/StateBuilder.kt | 3 ++ .../swap/StateBuilderInitialStateTest.kt | 3 ++ .../feature/swap/StateBuilderPairsTest.kt | 3 ++ .../feature/swap/StateBuilderQuotesTest.kt | 6 +-- .../feature/swap/StateBuilderSwapDataTest.kt | 5 ++- 9 files changed, 58 insertions(+), 30 deletions(-) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 6755e4209a..687fd60f96 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -185,6 +185,7 @@ internal class SwapModel @Inject constructor( appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), isAccountsModeProvider = Provider { isAccountsMode }, iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + appRouter = appRouter, ) private val inputNumberFormatter = InputNumberFormatter( @@ -1499,16 +1500,6 @@ internal class SwapModel @Inject constructor( ) } }, - onBuyClick = { - val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return@UiActions - val feePaidCryptoCurrency = dataState.feePaidCryptoCurrency ?: return@UiActions - val route = AppRoute.CurrencyDetails( - userWalletId = fromSwapCurrencyStatus.userWalletId, - currency = feePaidCryptoCurrency.currency, - ) - - appRouter.push(route) - }, onRetryClick = { startLoadingQuotesFromLastState() }, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 6f203e2e72..f4199313f4 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -1,5 +1,7 @@ package com.tangem.feature.swap.model +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification @@ -106,6 +108,7 @@ internal class SwapNotificationsFactory( selectedFeeType: FeeType, providerName: String, hideFee: Boolean, + appRouter: AppRouter, ): ImmutableList { val warnings = buildList { maybeAddRentExemptionError(quoteModel) @@ -113,7 +116,12 @@ internal class SwapNotificationsFactory( maybeAddNeedReserveToCreateAccountWarning(quoteModel) maybeAddPermissionNeededWarning(quoteModel, providerName) maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType) - maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, hideFee) + maybeAddUnableCoverFeeWarning( + quoteModel = quoteModel, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + hideFee = hideFee, + appRouter = appRouter, + ) maybeAddTransactionInProgressWarning(quoteModel) maybeAddPriceImpactNotification(quoteModel.priceImpact) } @@ -299,17 +307,20 @@ internal class SwapNotificationsFactory( } } + @Suppress("CyclomaticComplexMethod") private fun MutableList.maybeAddUnableCoverFeeWarning( quoteModel: SwapState.QuotesLoadedState, feeCryptoCurrencyStatus: CryptoCurrencyStatus?, hideFee: Boolean, + appRouter: AppRouter, ) { - if (hideFee) return - val fromCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus.currency + if (hideFee || feeCryptoCurrencyStatus == null) return + val fromSwapCurrency = quoteModel.fromTokenInfo.swapCurrencyStatus + val fromCurrency = fromSwapCurrency.currency val feeEnoughState = quoteModel.preparedSwapConfigState.feeState as? SwapFeeState.NotEnough val shouldShowCoverWarning = !quoteModel.preparedSwapConfigState.isBalanceEnough && quoteModel.permissionState !is PermissionDataState.PermissionLoading && - feeCryptoCurrencyStatus?.currency != fromCurrency + feeCryptoCurrencyStatus.currency != fromCurrency val isCEXProvider = quoteModel.swapProvider.type == ExchangeProviderType.CEX @@ -320,13 +331,25 @@ internal class SwapNotificationsFactory( if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) { add( - SwapNotificationUM.Error.UnableToCoverFeeWarning( - fromToken = fromCurrency, - feeCurrency = feeCryptoCurrencyStatus?.currency, - currencyName = feeEnoughState?.currencyName ?: fromCurrency.network.name, - currencySymbol = feeEnoughState?.currencySymbol ?: fromCurrency.network.currencySymbol, - onConfirmClick = actions.onBuyClick, - ), + if (fromCurrency.id == feeCryptoCurrencyStatus.currency.id) { + SwapNotificationUM.Error.InsufficientFunds + } else { + val route = AppRoute.CurrencyDetails( + userWalletId = fromSwapCurrency.userWalletId, + currency = feeCryptoCurrencyStatus.currency, + ) + SwapNotificationUM.Error.UnableToCoverFeeWarning( + fromToken = fromCurrency, + feeCurrency = feeCryptoCurrencyStatus.currency, + currencyName = feeEnoughState?.currencyName ?: fromCurrency.network.name, + currencySymbol = feeEnoughState?.currencySymbol ?: fromCurrency.network.currencySymbol, + onConfirmClick = if (!appRouter.stack.contains(route)) { + { appRouter.push(route) } + } else { + null + }, + ) + }, ) } } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index ac922cd6f9..73cb3aa224 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -20,7 +20,6 @@ internal data class UiActions( val onSelectFeeType: (TxFee.Legacy) -> Unit, val onProviderClick: (String) -> Unit, val onProviderSelect: (String) -> Unit, - val onBuyClick: () -> Unit, val onSelectTokenClick: (TokenSelectionDirection) -> Unit, val onSuccess: () -> Unit, val onLinkClick: (String) -> Unit, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt index 1cb665edc9..476edcbc27 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/states/SwapNotificationUM.kt @@ -55,12 +55,17 @@ internal object SwapNotificationUM { ), ) + data object InsufficientFunds : Error( + title = resourceReference(R.string.swapping_insufficient_funds), + subtitle = resourceReference(R.string.swapping_insufficient_funds_description), + ) + data class UnableToCoverFeeWarning( val fromToken: CryptoCurrency, val currencyName: String, val currencySymbol: String, - val feeCurrency: CryptoCurrency?, - val onConfirmClick: () -> Unit, + val feeCurrency: CryptoCurrency, + val onConfirmClick: (() -> Unit)?, ) : Error( title = resourceReference( R.string.warning_express_not_enough_fee_for_token_tx_title, @@ -71,7 +76,7 @@ internal object SwapNotificationUM { wrappedList(currencyName, currencySymbol), ), iconResId = fromToken.networkIconResId, - buttonState = feeCurrency?.let { + buttonState = onConfirmClick?.let { NotificationConfig.ButtonsState.SecondaryButtonConfig( text = resourceReference(R.string.common_buy_currency, wrappedList(currencySymbol)), onClick = onConfirmClick, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index deb2acdde2..e1b77b1fc2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -3,6 +3,7 @@ package com.tangem.feature.swap.ui import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.routing.AppRouter import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.account.CryptoPortfolioIconConverter @@ -58,6 +59,7 @@ internal class StateBuilder( private val appCurrencyProvider: Provider, private val isAccountsModeProvider: Provider, private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork, + private val appRouter: AppRouter, ) { private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter) @@ -463,6 +465,7 @@ internal class StateBuilder( selectedFeeType = selectedFeeType, providerName = swapProvider.name, hideFee = hideFee, + appRouter = appRouter, ) val fromAccountTitleUM = when { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt index e8724ad601..c59fbb8255 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.express.models.ExpressError import com.tangem.domain.models.account.Account @@ -31,6 +32,7 @@ internal class StateBuilderInitialStateTest { private val appCurrencyProvider: Provider = mockk() private val isAccountsModeProvider: Provider = mockk() private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val appRouter: AppRouter = mockk() private lateinit var sut: StateBuilder @@ -48,6 +50,7 @@ internal class StateBuilderInitialStateTest { appCurrencyProvider = appCurrencyProvider, isAccountsModeProvider = isAccountsModeProvider, iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + appRouter = appRouter ) } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt index c1f52f0e5d..6a4bd0b2e2 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderPairsTest.kt @@ -1,6 +1,7 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet @@ -26,6 +27,7 @@ internal class StateBuilderPairsTest { private val appCurrencyProvider: Provider = mockk() private val isAccountsModeProvider: Provider = mockk() private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val appRouter: AppRouter = mockk() private lateinit var sut: StateBuilder @@ -53,6 +55,7 @@ internal class StateBuilderPairsTest { appCurrencyProvider = appCurrencyProvider, isAccountsModeProvider = isAccountsModeProvider, iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + appRouter = appRouter, ) } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt index e335298820..fcc7fa39b6 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderQuotesTest.kt @@ -1,8 +1,8 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter 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 +14,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 @@ -32,6 +30,7 @@ internal class StateBuilderQuotesTest { private val appCurrencyProvider: Provider = mockk() private val isAccountsModeProvider: Provider = mockk() private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val appRouter: AppRouter = mockk() private lateinit var sut: StateBuilder @@ -60,6 +59,7 @@ internal class StateBuilderQuotesTest { appCurrencyProvider = appCurrencyProvider, isAccountsModeProvider = isAccountsModeProvider, iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + appRouter = appRouter, ) } diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt index ed55977ec8..fb7636352b 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt @@ -1,16 +1,15 @@ package com.tangem.feature.swap import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter 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 @@ -31,6 +30,7 @@ internal class StateBuilderSwapDataTest { private val appCurrencyProvider: Provider = mockk() private val isAccountsModeProvider: Provider = mockk() private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk() + private val appRouter: AppRouter = mockk() private lateinit var sut: StateBuilder @@ -59,6 +59,7 @@ internal class StateBuilderSwapDataTest { appCurrencyProvider = appCurrencyProvider, isAccountsModeProvider = isAccountsModeProvider, iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork, + appRouter = appRouter, ) } From 6d7227689881a2d4f9c84db6a238be9df82eb708 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 18 May 2026 06:07:20 -0700 Subject: [PATCH 194/206] Updated on 2026-08-14 --- .../datasource/di/TangemPayStoresModule.kt | 4 +- .../visa/DefaultTangemPayReissueCardStore.kt | 15 +++++-- .../entity/PaymentAccountStatusValueDM.kt | 1 + core/res/src/main/res/values/strings.xml | 1 + .../PaymentAccountStatusValueDMConverter.kt | 2 + .../tangem/data/pay/di/TangemPayDataModule.kt | 16 ++++++++ .../DefaultPaymentAccountStatusFetcher.kt | 16 +++++++- .../tangem/domain/models/pay/TangemPayCard.kt | 1 + .../usecase/ReissueTangemPayCardUseCase.kt | 33 ++++++++++++++++ .../TangemPayReissueCardComponent.kt | 2 - .../tangempay/model/TangemPayCardPageModel.kt | 39 ++++--------------- .../model/TangemPayReissueCardModel.kt | 11 +++--- .../tangempay/ui/TangemPayCardPageScreen.kt | 16 -------- .../ui/TangemPayReplacingCardBlock.kt | 35 +++++++++++++++++ .../setup/TangemPayCardLimitSetupModelTest.kt | 1 + .../converter/TangemPayMainBlockConverter.kt | 7 +++- 16 files changed, 138 insertions(+), 62 deletions(-) create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt index 135c8bfaa8..f0f88d8c24 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/TangemPayStoresModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore @@ -25,9 +26,10 @@ internal object TangemPayStoresModule { @Provides @Singleton - fun provideTangemPayReissueCardStore(): TangemPayReissueCardStore { + fun provideTangemPayReissueCardStore(prefs: AppPreferencesStore): TangemPayReissueCardStore { return DefaultTangemPayReissueCardStore( feeStore = RuntimeDataStore(), + prefs = prefs, ) } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt index 666b4e790c..bb25810fba 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/DefaultTangemPayReissueCardStore.kt @@ -1,11 +1,16 @@ package com.tangem.datasource.local.visa +import androidx.datastore.preferences.core.stringPreferencesKey import com.tangem.datasource.local.datastore.RuntimeDataStore +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.utils.getSyncOrNull +import com.tangem.datasource.local.preferences.utils.store import com.tangem.domain.models.pay.TangemPayReissueCardFee import com.tangem.domain.models.wallet.UserWalletId internal class DefaultTangemPayReissueCardStore( private val feeStore: RuntimeDataStore, + private val prefs: AppPreferencesStore, ) : TangemPayReissueCardStore { override suspend fun storeReissueFee( @@ -20,11 +25,15 @@ internal class DefaultTangemPayReissueCardStore( } override suspend fun storeReissueOrderId(cardId: String, orderId: String) { - // TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs + prefs.store( + key = getReissueKey(cardId), + value = orderId, + ) } override suspend fun getOrderId(cardId: String): String? { - // TODO v_rodionov: #[REDACTED_TASK_KEY] store orderId in app prefs - return null + return prefs.getSyncOrNull(key = getReissueKey(cardId)) } + + private fun getReissueKey(cardId: String) = stringPreferencesKey("tangem_pay_reissue_card_$cardId") } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt index 1f74827d91..a3937b260b 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/visa/entity/PaymentAccountStatusValueDM.kt @@ -85,5 +85,6 @@ sealed interface PaymentAccountStatusValueDM { @Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?, @Json(name = "is_frozen") val isFrozen: Boolean, @Json(name = "last_digits") val lastDigits: String, + @Json(name = "is_reissuing") val isReissuing: Boolean, ) } \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index f8c7c79e89..73aedfe1a8 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1766,6 +1766,7 @@ Unable to display details. However, card payments are still working. Set \nPIN code Card deactivated + Replacing your card Session expired Renew session Use USDC for everyday payments diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 0c2fd37b83..a8df3bf885 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -51,6 +51,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( adminDailyLimit = card.limit?.adminCardLimit?.amount, isFrozen = card.isFrozen, lastDigits = card.lastDigits, + isReissuing = card.isReissuing, ) }, ) @@ -104,6 +105,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( ), isFrozen = card.isFrozen, lastDigits = card.lastDigits, + isReissuing = card.isReissuing, ) }, ) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index aea6884b96..599536adc0 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -29,6 +29,7 @@ import com.tangem.domain.pay.repository.* import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase +import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase import com.tangem.domain.pay.usecase.SetTangemPayCardLimitUseCase import com.tangem.domain.pay.usecase.StartTangemPayOrderPollingUseCase import com.tangem.domain.pay.usecase.UpdateTangemPayCardNameUseCase @@ -196,5 +197,20 @@ internal interface TangemPayDataModule { ): StartTangemPayOrderPollingUseCase { return StartTangemPayOrderPollingUseCase(cardDetailsRepository, paymentAccountStatusFetcher) } + + @Provides + fun provideReissueTangemPayCardUseCase( + reissueCardRepository: TangemPayReissueCardRepository, + startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + appCoroutineScope: AppCoroutineScope, + paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + ): ReissueTangemPayCardUseCase { + return ReissueTangemPayCardUseCase( + reissueCardRepository = reissueCardRepository, + startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase, + appCoroutineScope = appCoroutineScope, + paymentAccountStatusFetcher = paymentAccountStatusFetcher, + ) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index ca4dad4ed3..951f04e9a1 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -20,6 +20,7 @@ import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.CustomerOrderRepository import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.domain.visa.error.VisaApiError import com.tangem.domain.visa.model.TangemPayCardFrozenState import com.tangem.security.DeviceSecurityInfoProvider @@ -43,6 +44,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val dispatchers: CoroutineDispatcherProvider, private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, private val eligibilityManager: TangemPayEligibilityManager, + private val reissueCardRepository: TangemPayReissueCardRepository, ) : PaymentAccountStatusFetcher { private val logger = TangemLogger.withTag(TAG) @@ -254,7 +256,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ) } - private fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { + private suspend fun CustomerInfo.mapToPaymentAccountStatus(userWalletId: UserWalletId): PaymentAccountStatusValue { val cardInfo = this.cardInfo val productInstance = this.productInstance @@ -286,12 +288,21 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( } } - private fun convertToContentState( + private suspend fun convertToContentState( userWalletId: UserWalletId, productInstance: CustomerInfo.ProductInstance, cardInfo: CustomerInfo.CardInfo, customerId: String, ): PaymentAccountStatusValue { + val reissueOrder = reissueCardRepository.getReissueOrderInfo( + userWalletId = userWalletId, + cardId = productInstance.cardId, + ).getOrNull() + + val isReissuing = reissueOrder != null && + reissueOrder.orderStatus != OrderStatus.CANCELED && + reissueOrder.orderStatus != OrderStatus.COMPLETED + val cryptoCurrency = tangemPayCurrencyFactory.create(userWalletId) return PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, @@ -313,6 +324,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( ), isFrozen = productInstance.frozenState is TangemPayCardFrozenState.Frozen, lastDigits = cardInfo.lastFourDigits, + isReissuing = isReissuing, ), ), ) diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt index 115e387985..621ef7e2d0 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayCard.kt @@ -22,4 +22,5 @@ data class TangemPayCard( @SerialName("limit") val limit: TangemPayCardLimitData?, @SerialName("is_frozen") val isFrozen: Boolean, @SerialName("last_digits") val lastDigits: String, + @SerialName("is_reissuing") val isReissuing: Boolean, ) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt new file mode 100644 index 0000000000..7420980320 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/ReissueTangemPayCardUseCase.kt @@ -0,0 +1,33 @@ +package com.tangem.domain.pay.usecase + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher +import com.tangem.domain.pay.model.OrderStatus +import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.utils.coroutines.AppCoroutineScope +import kotlinx.coroutines.launch + +class ReissueTangemPayCardUseCase( + private val reissueCardRepository: TangemPayReissueCardRepository, + private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase, + private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher, + private val appCoroutineScope: AppCoroutineScope, +) { + suspend operator fun invoke(userWalletId: UserWalletId, cardId: String): Either = either { + val order = reissueCardRepository.reissueCard(userWalletId, cardId).bind() + + if (order.orderStatus == OrderStatus.CANCELED) { + raise(VisaApiError.Unspecified) + } + + reissueCardRepository.storeReissueOrderId(cardId, order.orderId) + paymentAccountStatusFetcher.invoke(userWalletId) + + appCoroutineScope.launch { + startTangemPayOrderPollingUseCase(order, userWalletId) + } + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt index 918ad5d9af..dcb213790d 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayReissueCardComponent.kt @@ -7,7 +7,6 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.features.tangempay.model.TangemPayReissueCardModel import com.tangem.features.tangempay.ui.TangemPayReissueCardContent @@ -34,7 +33,6 @@ internal class TangemPayReissueCardComponent( } internal interface ReissueCardListener { - fun onReissueOrderCreate(order: TangemPayOrderInfo) fun onDismissReissueCard() fun onClickAddFunds() } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index c0f9d07ad2..54dd034211 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -28,11 +28,8 @@ import com.tangem.domain.models.account.requireCardWithId import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier -import com.tangem.domain.pay.model.OrderStatus -import com.tangem.domain.pay.model.TangemPayOrderInfo import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository -import com.tangem.domain.pay.repository.TangemPayReissueCardRepository import com.tangem.domain.pay.usecase.ChangeCardFrozenStateUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.AddFundsListener @@ -63,7 +60,6 @@ internal class TangemPayCardPageModel @Inject constructor( private val analytics: AnalyticsEventHandler, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val uiMessageSender: UiMessageSender, - private val reissueCardRepository: TangemPayReissueCardRepository, private val changeCardFrozenStateUseCase: ChangeCardFrozenStateUseCase, ) : Model(), ViewPinListener, ReissueCardListener, AddFundsListener { @@ -84,7 +80,6 @@ internal class TangemPayCardPageModel @Inject constructor( val bottomSheetNavigation: SlotNavigation = SlotNavigation() - // TODO v_rodionov: #[REDACTED_TASK_KEY] check reissue order state before card details are showed init { analytics.send(TangemPayAnalyticsEvents.CardManagementScreenOpened()) fetchAddToWalletBanner() @@ -109,7 +104,13 @@ internal class TangemPayCardPageModel @Inject constructor( } else { TangemPayDailyLimitBlockState.Error } - uiState.update { it.copy(dailyLimitState = dailyLimitState, settings = buildSettings(card)) } + uiState.update { uiState -> + uiState.copy( + dailyLimitState = dailyLimitState, + settings = buildSettings(card), + isReissueInProgress = card.isReissuing, + ) + } } else { uiState.update { it.copy(dailyLimitState = TangemPayDailyLimitBlockState.Error) } } @@ -176,18 +177,6 @@ internal class TangemPayCardPageModel @Inject constructor( bottomSheetNavigation.activate(TangemPayCardNavigation.ReissueCard) } - override fun onReissueOrderCreate(order: TangemPayOrderInfo) { - bottomSheetNavigation.dismiss() - onReissueOrderStatusReceived(order.orderStatus) - if (order.orderStatus != OrderStatus.CANCELED) { - modelScope.launch { - reissueCardRepository.storeReissueOrderId(params.config.cardId, order.orderId) - } - } else { - uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong))) - } - } - override fun onDismissReissueCard() { bottomSheetNavigation.dismiss() } @@ -314,18 +303,4 @@ internal class TangemPayCardPageModel @Inject constructor( override fun onDismissViewPin() { bottomSheetNavigation.dismiss() } - - private fun onReissueOrderStatusReceived(orderStatus: OrderStatus) { - when (orderStatus) { - OrderStatus.NEW, OrderStatus.PROCESSING, OrderStatus.COMPLETED -> { - uiState.update { state -> - state.copy( - addToWalletBlockState = null, - isReissueInProgress = true, - ) - } - } - OrderStatus.CANCELED -> Unit - } - } } \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt index 6c454671a1..ff2a256c1f 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayReissueCardModel.kt @@ -13,6 +13,7 @@ import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.message.SnackbarMessage import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository import com.tangem.domain.pay.repository.TangemPayReissueCardRepository +import com.tangem.domain.pay.usecase.ReissueTangemPayCardUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.features.tangempay.components.TangemPayReissueCardComponent import com.tangem.features.tangempay.details.impl.R @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("LongParameterList") @Stable @ModelScoped internal class TangemPayReissueCardModel @Inject constructor( @@ -36,6 +38,7 @@ internal class TangemPayReissueCardModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val reissueCardRepository: TangemPayReissueCardRepository, + private val reissueTangemPayCardUseCase: ReissueTangemPayCardUseCase, private val uiMessageSender: UiMessageSender, private val analytics: AnalyticsEventHandler, ) : Model() { @@ -72,15 +75,13 @@ internal class TangemPayReissueCardModel @Inject constructor( analytics.send(TangemPayAnalyticsEvents.ReplaceCardConfirmed()) state.update { it.copy(isReissuingInProgress = true) } modelScope.launch { - reissueCardRepository.reissueCard( + reissueTangemPayCardUseCase( userWalletId = params.userWalletId, cardId = params.cardId, ).onLeft { uiMessageSender.send(SnackbarMessage(resourceReference(R.string.common_something_went_wrong))) - onDismiss() - }.onRight { order -> - params.listener.onReissueOrderCreate(order) } + onDismiss() }.saveIn(reissueJobHolder) } @@ -96,7 +97,7 @@ internal class TangemPayReissueCardModel @Inject constructor( val error = if (fee == null || cardBalance == null) { TangemPayReissueCardError.InitialDataLoading - } else if (cardBalance.availableForWithdrawal < fee.amount) { + } else if (cardBalance.fiatBalance < fee.amount) { TangemPayReissueCardError.InsufficientFunds } else { null diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt index 8ea4e2139d..f225204b8b 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardPageScreen.kt @@ -24,10 +24,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.util.fastForEach import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.components.notifications.Notification -import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview @@ -102,19 +99,6 @@ internal fun TangemPayCardPageScreen( } } -@Composable -private fun TangemPayReplacingCardBlock(modifier: Modifier = Modifier) { - Notification( - modifier = modifier, - config = NotificationConfig( - iconResId = com.tangem.core.ui.R.drawable.ic_update_32, - iconTint = NotificationConfig.IconTint.Accent, - title = resourceReference(R.string.tangempay_reissue_card_in_progress), - subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description), - ), - ) -} - @Composable private fun TangemPayCardPageSettingsBlock( settings: ImmutableList, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt new file mode 100644 index 0000000000..ac1af73851 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayReplacingCardBlock.kt @@ -0,0 +1,35 @@ +package com.tangem.features.tangempay.ui + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.components.notifications.Notification +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.features.tangempay.details.impl.R + +@Composable +internal fun TangemPayReplacingCardBlock(modifier: Modifier = Modifier) { + Notification( + modifier = modifier, + config = NotificationConfig( + iconResId = R.drawable.ic_update_32, + iconTint = NotificationConfig.IconTint.Accent, + title = resourceReference(R.string.tangempay_reissue_card_in_progress), + subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description), + ), + containerColor = TangemTheme.colors.background.primary, + ) +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview() { + TangemThemePreview { + TangemPayReplacingCardBlock() + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt index 6de5f4d546..822ebcf651 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/limit/setup/TangemPayCardLimitSetupModelTest.kt @@ -74,6 +74,7 @@ internal class TangemPayCardLimitSetupModelTest { ) } ), + isReissuing = false, ) val statusWithLimit: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { every { source } returns StatusSource.ACTUAL diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt index 9a7844cdeb..f5b9d0c634 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TangemPayMainBlockConverter.kt @@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers.convert import androidx.compose.ui.text.SpanStyle import com.tangem.common.ui.R import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format @@ -85,7 +86,11 @@ internal class TangemPayMainBlockConverter( is PaymentAccountStatusValue.Loaded -> { val card = statusValue.cards.firstOrNull() ?: return TangemPayMainUM.TemporaryUnavailable TangemPayMainUM.Content( - subtitle = stringReference("*${card.lastDigits}"), + subtitle = if (card.isReissuing) { + resourceReference(R.string.tangempay_status_replacing) + } else { + stringReference("*${card.lastDigits}") + }, isBalanceFlickering = statusValue.source == StatusSource.CACHE, balance = getBalanceText( currencyCode = statusValue.currencyCode, From 75af6a807e4edd2d96f952dda5734cd5add37199 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 18 May 2026 17:23:25 +0300 Subject: [PATCH 195/206] Updated on 2026-08-14 --- app/src/main/assets/tangem-app-config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/tangem-app-config b/app/src/main/assets/tangem-app-config index 498b0bcd0d..158fbd8808 160000 --- a/app/src/main/assets/tangem-app-config +++ b/app/src/main/assets/tangem-app-config @@ -1 +1 @@ -Subproject commit 498b0bcd0d871ed60c43b5d44f646548a4f11d37 +Subproject commit 158fbd8808d2db92ef82d3f9ed92c81340c707c5 From 0cceaf31107a9e786192190b5e0c425b54a75041 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 19 May 2026 10:06:50 +0300 Subject: [PATCH 196/206] Updated on 2026-08-14 --- .../src/main/kotlin/com/tangem/lib/visa/utils/Constants.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/Constants.kt b/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/Constants.kt index 06e365e4ec..1331b995b3 100644 --- a/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/Constants.kt +++ b/libs/visa/src/main/kotlin/com/tangem/lib/visa/utils/Constants.kt @@ -10,9 +10,6 @@ internal object Constants { const val GAS_LIMIT = 500_000_000L const val PRIVATE_KEY_LENGTH = 32 - const val VISA_API_PROD_URL = "https://payapi.tangem-tech.com/api/v1/" - const val VISA_API_DEV_URL = "[REDACTED_ENV_URL]" - const val NETWORK_TIMEOUT_SECONDS = 65L const val NETWORK_LOGS_TAG = "VisaNetworkLogs" } \ No newline at end of file From d99a6bf31efc411cfa331c72202d80cb17f2555b Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 19 May 2026 01:39:48 -0700 Subject: [PATCH 197/206] Updated on 2026-08-14 --- .../domain/pay/TangemPayDetailsConfig.kt | 1 + ...faultTangemPayDetailsContainerComponent.kt | 4 +- .../entity/TangemPayDetailsStateFactory.kt | 9 +- .../tangempay/entity/TangemPayDetailsUM.kt | 6 +- .../tangempay/model/TangemPayDetailsModel.kt | 30 +++++-- .../TangemPayCardDataTransformer.kt | 30 +++++++ .../TangemPayAccountDetailsInnerRoute.kt | 3 +- .../tangempay/ui/TangemPayDetailsScreen.kt | 88 +++++++++++++------ .../tangempay/utils/TangemPayDetailIntents.kt | 3 +- .../setup/TangemPayCardLimitSetupModelTest.kt | 1 + .../converter/TangemPayMainBlockConverter.kt | 2 + 11 files changed, 137 insertions(+), 40 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt index f94eb30195..8bbd95fb92 100644 --- a/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/pay/TangemPayDetailsConfig.kt @@ -11,6 +11,7 @@ data class TangemPayDetailsConfig( val isPinSet: Boolean, val cardFrozenState: TangemPayCardFrozenState, val cardNumberEnd: String, + val isReissuing: Boolean, val chainId: Int, val isTangemPayDeactivated: Boolean, val displayName: CardDisplayName?, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index 94de672f75..885bf7e9bc 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -65,9 +65,9 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru tokenReceiveComponentFactory = tokenReceiveComponentFactory, expressTransactionsComponentProvider = expressTransactionsComponentProvider, ) - TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create( + is TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create( context = childByContext(componentContext = componentContext, router = innerRouter), - params = TangemPayCardPageComponent.Params(userWalletId = params.userWalletId, config = params.config), + params = TangemPayCardPageComponent.Params(userWalletId = params.userWalletId, config = config.config), ) TangemPayAccountDetailsInnerRoute.AddToWallet -> TangemPayAddToWalletComponent( appComponentContext = childByContext(componentContext = componentContext, router = innerRouter), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 2f630968ac..f0cddecb45 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -21,7 +21,11 @@ internal class TangemPayDetailsStateFactory( private val cardFrozenState: TangemPayCardFrozenState, ) { @Suppress("LongMethod") - fun getInitialState(isTangemPayDeactivated: Boolean, cardNumberEnd: String): TangemPayDetailsUM { + fun getInitialState( + isTangemPayDeactivated: Boolean, + cardNumberEnd: String, + isReissuing: Boolean, + ): TangemPayDetailsUM { return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, @@ -51,7 +55,8 @@ internal class TangemPayDetailsStateFactory( cards = persistentListOf( TangemPayDetailsBalanceBlockState.Card( lastDigits = cardNumberEnd, - onClick = intents::onCardClick, + onClick = {}, + isReissuing = isReissuing, ), ), onAddCardClick = intents::onAddCardClick, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 2a192392ec..a86c20d4d4 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -84,7 +84,11 @@ internal sealed class TangemPayDetailsBalanceBlockState { ) : TangemPayDetailsBalanceBlockState() data class CardsBlockState(val cards: ImmutableList, val onAddCardClick: () -> Unit) - data class Card(val lastDigits: String, val onClick: () -> Unit) + data class Card( + val lastDigits: String, + val onClick: () -> Unit, + val isReissuing: Boolean, + ) } internal data class AddToWalletBlockState( diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index dded4437b7..9f2a9aa630 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -19,10 +19,14 @@ import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.feedback.SendFeedbackEmailUseCase import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.feedback.models.WalletMetaInfo +import com.tangem.domain.models.StatusSource import com.tangem.domain.models.TokenReceiveConfig +import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.TangemPayCryptoCurrencyFactory +import com.tangem.domain.pay.TangemPayDetailsConfig +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.model.TangemPayCardBalance import com.tangem.domain.pay.model.TangemPayTopUpData import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository @@ -53,10 +57,7 @@ import com.tangem.utils.coroutines.saveIn import com.tangem.utils.logging.TangemLogger import com.tangem.utils.transformer.update import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -65,6 +66,7 @@ import javax.inject.Inject @ModelScoped internal class TangemPayDetailsModel @Inject constructor( paramsContainer: ParamsContainer, + paymentAccountStatusSupplier: PaymentAccountStatusSupplier, override val dispatchers: CoroutineDispatcherProvider, private val analytics: AnalyticsEventHandler, private val router: Router, @@ -95,6 +97,7 @@ internal class TangemPayDetailsModel @Inject constructor( stateFactory.getInitialState( isTangemPayDeactivated = params.config.isTangemPayDeactivated, cardNumberEnd = params.config.cardNumberEnd, + isReissuing = params.config.isReissuing, ), ) @@ -118,6 +121,21 @@ internal class TangemPayDetailsModel @Inject constructor( if (!params.config.isTangemPayDeactivated) { subscribeToCardFrozenState() fetchAddToWalletBanner() + + paymentAccountStatusSupplier.invoke(params.userWalletId) + .map { it.value } + .filterIsInstance() + .filter { it.source == StatusSource.ACTUAL } + .onEach { state -> + val card = state.cards.firstOrNull() ?: return@onEach + uiState.update( + TangemPayCardDataTransformer( + card = card, + onCardClick = { onCardClick(params.config.copy(cardId = card.id)) }, + ), + ) + } + .launchIn(modelScope) } } @@ -363,9 +381,9 @@ internal class TangemPayDetailsModel @Inject constructor( urlOpener.openUrl(TangemPayConstants.TERMS_AND_LIMITS_LINK) } - override fun onCardClick() { + override fun onCardClick(config: TangemPayDetailsConfig) { analytics.send(TangemPayAnalyticsEvents.CardIconClicked()) - router.push(TangemPayAccountDetailsInnerRoute.CardDetails) + router.push(TangemPayAccountDetailsInnerRoute.CardDetails(config)) } override fun onAddCardClick() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt new file mode 100644 index 0000000000..cd15656cc3 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayCardDataTransformer.kt @@ -0,0 +1,30 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf + +internal class TangemPayCardDataTransformer( + private val card: TangemPayCard, + private val onCardClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + val updatedCard = TangemPayDetailsBalanceBlockState.Card( + lastDigits = card.lastDigits, + onClick = onCardClick, + isReissuing = card.isReissuing, + ) + val cardsBlockState = prevState.balanceBlockState.cardsBlockState.copy( + cards = persistentListOf(updatedCard), + ) + val newBalanceBlockState = when (val bs = prevState.balanceBlockState) { + is TangemPayDetailsBalanceBlockState.Loading -> bs.copy(cardsBlockState = cardsBlockState) + is TangemPayDetailsBalanceBlockState.Content -> bs.copy(cardsBlockState = cardsBlockState) + is TangemPayDetailsBalanceBlockState.Error -> bs.copy(cardsBlockState = cardsBlockState) + } + return prevState.copy(balanceBlockState = newBalanceBlockState) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt index 8ef5c5e9a3..b49fa0a643 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/navigation/TangemPayAccountDetailsInnerRoute.kt @@ -1,6 +1,7 @@ package com.tangem.features.tangempay.navigation import com.tangem.core.decompose.navigation.Route +import com.tangem.domain.pay.TangemPayDetailsConfig import kotlinx.serialization.Serializable @Serializable @@ -9,7 +10,7 @@ internal sealed class TangemPayAccountDetailsInnerRoute : Route { data object AccountDetails : TangemPayAccountDetailsInnerRoute() @Serializable - data object CardDetails : TangemPayAccountDetailsInnerRoute() + data class CardDetails(val config: TangemPayDetailsConfig) : TangemPayAccountDetailsInnerRoute() @Serializable data object AddToWallet : TangemPayAccountDetailsInnerRoute() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index fabad9ef58..7044edc626 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.ui.components.RectangleShimmer @@ -114,30 +115,45 @@ internal fun TangemPayDetailsScreen( SpacerH12() }, ) - if (state.addToWalletBlockState != null) { + + if (state.balanceBlockState.cardsBlockState.cards.fastAny { it.isReissuing }) { item( - key = AddToWalletBlockState::class.java, + key = "REISSUE_MESSAGE", content = { - TangemPayAddToWalletBlock( - state = state.addToWalletBlockState, - modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), - ) - }, - ) - } - if (state.accountDeactivatedNotificationConfig != null) { - item( - key = "DEACTIVATION_MESSAGE", - content = { - Notification( - modifier = modifier + TangemPayReplacingCardBlock( + modifier = Modifier .padding(horizontal = TangemTheme.dimens.spacing16) .fillMaxWidth(), - config = state.accountDeactivatedNotificationConfig, ) SpacerH12() }, ) + } else { + if (state.addToWalletBlockState != null) { + item( + key = AddToWalletBlockState::class.java, + content = { + TangemPayAddToWalletBlock( + state = state.addToWalletBlockState, + modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), + ) + }, + ) + } + if (state.accountDeactivatedNotificationConfig != null) { + item( + key = "DEACTIVATION_MESSAGE", + content = { + Notification( + modifier = modifier + .padding(horizontal = TangemTheme.dimens.spacing16) + .fillMaxWidth(), + config = state.accountDeactivatedNotificationConfig, + ) + SpacerH12() + }, + ) + } } if (state.accountDeactivatedNotificationConfig == null) { with(expressTransactionsComponent) { @@ -289,14 +305,26 @@ private fun TangemPayCardItem(card: TangemPayDetailsBalanceBlockState.Card, modi painter = painterResource(R.drawable.img_visa_card_48x32), contentDescription = null, ) - Text( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(4.dp, bottom = 2.dp), - text = card.lastDigits, - style = TangemTheme.typography.overline.copy(letterSpacing = 0.sp), - color = TangemTheme.colors.text.constantWhite, - ) + if (card.isReissuing) { + Icon( + modifier = Modifier + .size(16.dp) + .align(Alignment.BottomStart) + .padding(2.dp), + painter = painterResource(R.drawable.ic_update_32), + contentDescription = null, + tint = TangemTheme.colors.text.constantWhite, + ) + } else { + Text( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(4.dp, bottom = 2.dp), + text = card.lastDigits, + style = TangemTheme.typography.overline.copy(letterSpacing = 0.sp), + color = TangemTheme.colors.text.constantWhite, + ) + } } } @@ -421,8 +449,8 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider Date: Tue, 19 May 2026 08:41:29 +0000 Subject: [PATCH 198/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index a0cb218ffd..76d48569f9 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.37-1508" +tangemBlockchainSdk = "releases-5.38-1512" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.37-608" +tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 9678c49b374ab090c64df5ff09501e3286aea04a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 May 2026 12:30:46 +0400 Subject: [PATCH 199/206] Updated on 2026-08-14 --- .../core/analytics/models/AnalyticsParam.kt | 15 +++ .../com/tangem/core/analytics/models/Basic.kt | 1 + .../ethereum/WcEthSendTransactionUseCase.kt | 2 + .../analytics/StakingAnalyticsEvent.kt | 2 + .../domain/walletconnect/WcAnalyticEvents.kt | 2 + .../approval/impl/model/GiveApprovalModel.kt | 9 +- .../v2/send/analytics/SendAnalyticEvents.kt | 2 + .../v2/send/analytics/SendAnalyticHelper.kt | 7 ++ .../analytics/NFTSendAnalyticHelper.kt | 1 + .../analytics/utils/StakingAnalyticSender.kt | 5 +- .../analytics/SendWithSwapAnalyticEvents.kt | 2 + .../confirm/model/SendWithSwapConfirmModel.kt | 10 +- .../feature/swap/analytics/SwapEvents.kt | 3 + .../tangem/feature/swap/model/SwapModel.kt | 22 +++- .../approve/model/YieldSupplyApproveModel.kt | 109 ++++++++++-------- .../model/YieldSupplyStartEarningModel.kt | 10 +- .../model/YieldSupplyStopEarningModel.kt | 6 + 17 files changed, 151 insertions(+), 57 deletions(-) diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt index 9a0e69c86b..00e4585c49 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/AnalyticsParam.kt @@ -108,6 +108,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Send"), TxData data class Swap( @@ -115,6 +116,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Swap"), TxData data class Staking( @@ -122,6 +124,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Staking"), TxData data class Approve( @@ -129,6 +132,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, val permissionType: String, ) : TxSentFrom("Approve"), TxData @@ -137,6 +141,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType?, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("WalletConnect"), TxData data object Sell : TxSentFrom("Sell") @@ -146,6 +151,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("NFT"), TxData data class SendWithSwap( @@ -153,6 +159,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Send&Swap"), TxData data class Earning( @@ -160,6 +167,7 @@ sealed class AnalyticsParam { override val token: String, override val feeType: FeeType, override val feeToken: String, + override val feeAssetType: FeeAssetType, ) : TxSentFrom("Earning"), TxData } @@ -168,6 +176,12 @@ sealed class AnalyticsParam { val token: String val feeToken: String val feeType: FeeType? + val feeAssetType: FeeAssetType + } + + sealed class FeeAssetType(val value: String) { + data object Coin : FeeAssetType("Coin") + data object Token : FeeAssetType("Token") } sealed class FeeType(val value: String) { @@ -298,6 +312,7 @@ sealed class AnalyticsParam { const val SEARCHED = "Searched" const val RATE_TYPE = "Rate Type" const val SCREEN_TYPE = "Screen Type" + const val FEE_ASSET_TYPE = "Fee Asset Type" } } diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt index 0e916a0e2d..1486944a36 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/Basic.kt @@ -94,6 +94,7 @@ sealed class Basic( this[AnalyticsParam.Key.FEE_TYPE] = it } this[AnalyticsParam.Key.FEE_TOKEN] = sentFrom.feeToken + this[AnalyticsParam.Key.FEE_ASSET_TYPE] = sentFrom.feeAssetType.value } if (sentFrom is AnalyticsParam.TxSentFrom.Approve) { this[AnalyticsParam.Key.PERMISSION_TYPE] = sentFrom.permissionType diff --git a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt index 0bee94b52c..3f24678ad7 100644 --- a/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt +++ b/data/wallet-connect/src/main/kotlin/com/tangem/data/walletconnect/network/ethereum/WcEthSendTransactionUseCase.kt @@ -7,6 +7,7 @@ import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.extensions.formatHex import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.TxSentFrom import com.tangem.core.analytics.models.Basic import com.tangem.core.analytics.models.Basic.TransactionSent.MemoType @@ -91,6 +92,7 @@ internal class WcEthSendTransactionUseCase @AssistedInject constructor( token = network.currencySymbol, feeType = null, feeToken = network.currencySymbol, + feeAssetType = AnalyticsParam.FeeAssetType.Coin, ), memoType = MemoType.Null, ), diff --git a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt index 8b4e35aa0d..65407e56ca 100644 --- a/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt +++ b/domain/staking/src/main/java/com/tangem/domain/staking/analytics/StakingAnalyticsEvent.kt @@ -46,11 +46,13 @@ sealed class StakingAnalyticsEvent( data class StakeInProgressScreenOpened( val validator: String, val action: StakingActionType, + val feeAssetType: AnalyticsParam.FeeAssetType, ) : StakingAnalyticsEvent( event = "Stake In Progress Screen Opened", params = mapOf( "Validator" to validator, "Action" to action.asAnalyticName, + AnalyticsParam.Key.FEE_ASSET_TYPE to feeAssetType.value, ), ), AppsFlyerIncludedEvent diff --git a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt index 7097a4b00b..d7a799f3d5 100644 --- a/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt +++ b/domain/wallet-connect/src/main/kotlin/com/tangem/domain/walletconnect/WcAnalyticEvents.kt @@ -157,6 +157,7 @@ sealed class WcAnalyticEvents( network: Network, securityStatus: CheckDAppResult, accountDerivation: Int?, + feeAssetType: AnalyticsParam.FeeAssetType = AnalyticsParam.FeeAssetType.Coin, ) : WcAnalyticEvents( event = "Signature Request Handled", params = buildMap { @@ -168,6 +169,7 @@ sealed class WcAnalyticEvents( accountDerivation?.let { put(AnalyticsParam.ACCOUNT_DERIVATION, it.toString()) } + put(AnalyticsParam.Key.FEE_ASSET_TYPE, feeAssetType.value) }, ), AppsFlyerIncludedEvent diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt index dbe9d28ed2..2a82f3cbdb 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt @@ -266,7 +266,13 @@ internal class GiveApprovalModel @Inject constructor( private fun sendApproveSuccessAnalytics(feeContent: FeeSelectorUM.Content) { val currency = params.cryptoCurrencyStatus.currency - val feeToken = feeContent.feeExtraInfo.feeCryptoCurrencyStatus.currency.symbol + val feeCurrency = feeContent.feeExtraInfo.feeCryptoCurrencyStatus.currency + val feeToken = feeCurrency.symbol + val feeAssetType = if (feeCurrency is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val permissionType = when (uiState.value.approveType) { ApproveType.LIMITED -> "Current transaction" ApproveType.UNLIMITED -> "Unlimited" @@ -276,6 +282,7 @@ internal class GiveApprovalModel @Inject constructor( token = currency.symbol, feeType = feeContent.toAnalyticType(), feeToken = feeToken, + feeAssetType = feeAssetType, permissionType = permissionType, ) analyticsEventHandler.send( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt index e288c415ef..73d13815e7 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticEvents.kt @@ -30,6 +30,7 @@ internal sealed class SendAnalyticEvents( val isNonceNotEmpty: Boolean, private val ensStatus: AnalyticsParam.EmptyFull, private val feeToken: String, + private val feeAssetType: AnalyticsParam.FeeAssetType, private val fromDerivationIndex: Int?, private val toDerivationIndex: Int?, ) : SendAnalyticEvents( @@ -47,6 +48,7 @@ internal sealed class SendAnalyticEvents( } put(ENS_ADDRESS, ensAddress) put(FEE_TOKEN, feeToken) + put(AnalyticsParam.Key.FEE_ASSET_TYPE, feeAssetType.value) }, ), AppsFlyerIncludedEvent diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt index c3d536b383..8ab32e31a1 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/analytics/SendAnalyticHelper.kt @@ -31,6 +31,11 @@ internal class SendAnalyticHelper @Inject constructor( val feeSelectorUM = sendUM.feeSelectorUM as? FeeSelectorUM.Content ?: return val feeType = feeSelectorUM.toAnalyticType() val feeTokenSymbol = feeToken.symbol + val feeAssetType = if (feeToken is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val fromDerivationIndex = account?.derivationIndex?.value val destination = destinationUM?.addressTextField?.actualAddress ?: return val destinationAccount = getAccountCurrencyByAddressUseCase(destination) @@ -46,6 +51,7 @@ internal class SendAnalyticHelper @Inject constructor( fromDerivationIndex = fromDerivationIndex, toDerivationIndex = toDerivationIndex, feeToken = feeTokenSymbol, + feeAssetType = feeAssetType, ), ) analyticsEventHandler.send( @@ -55,6 +61,7 @@ internal class SendAnalyticHelper @Inject constructor( token = cryptoCurrency.symbol, feeType = feeType, feeToken = feeTokenSymbol, + feeAssetType = feeAssetType, ), memoType = getSendTransactionMemoType(destinationUM.memoTextField), ), diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/analytics/NFTSendAnalyticHelper.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/analytics/NFTSendAnalyticHelper.kt index b8c6f2e66c..5172f13211 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/analytics/NFTSendAnalyticHelper.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/analytics/NFTSendAnalyticHelper.kt @@ -37,6 +37,7 @@ internal class NFTSendAnalyticHelper @Inject constructor( token = NFT_SEND_CATEGORY, // should send "NFT" in token param feeType = feeType, feeToken = cryptoCurrency.symbol, + feeAssetType = AnalyticsParam.FeeAssetType.Coin, ), memoType = getSendTransactionMemoType(destinationUM?.memoTextField), ), diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt index d02f06b8ba..7d1a8d7044 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/analytics/utils/StakingAnalyticSender.kt @@ -73,6 +73,7 @@ internal class StakingAnalyticSender( feeType = AnalyticsParam.FeeType.Normal, permissionType = ApproveType.LIMITED.name, feeToken = tokenCryptoCurrency.symbol, + feeAssetType = AnalyticsParam.FeeAssetType.Coin, ), memoType = Basic.TransactionSent.MemoType.Null, ), @@ -82,7 +83,7 @@ internal class StakingAnalyticSender( fun sendTransactionStakingAnalytics(value: StakingUiState, cryptoCurrencyStatus: CryptoCurrencyStatus) { val validatorState = value.validatorState as? StakingStates.ValidatorState.Data val validatorName = validatorState?.chosenTarget?.name ?: return - + val feeAssetType = AnalyticsParam.FeeAssetType.Coin // support only coin as fee asset for staking transactions analyticsEventHandler.send( Basic.TransactionSent( sentFrom = AnalyticsParam.TxSentFrom.Staking( @@ -90,6 +91,7 @@ internal class StakingAnalyticSender( token = value.cryptoCurrencySymbol, feeType = AnalyticsParam.FeeType.Normal, feeToken = cryptoCurrencyStatus.currency.symbol, + feeAssetType = feeAssetType, ), memoType = Basic.TransactionSent.MemoType.Null, ), @@ -98,6 +100,7 @@ internal class StakingAnalyticSender( StakingAnalyticsEvent.StakeInProgressScreenOpened( validator = validatorName, action = getStakingActionType(value), + feeAssetType = feeAssetType, ), ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt index 5a68284603..e51dca3a75 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/analytics/SendWithSwapAnalyticEvents.kt @@ -61,6 +61,7 @@ internal sealed class SendWithSwapAnalyticEvents( val feeType: AnalyticsParam.FeeType, val fromToken: CryptoCurrency, val toToken: CryptoCurrency, + val feeAssetType: AnalyticsParam.FeeAssetType, val fromDerivationIndex: Int?, val toDerivationIndex: Int?, ) : SendWithSwapAnalyticEvents( @@ -74,6 +75,7 @@ internal sealed class SendWithSwapAnalyticEvents( put(RECEIVE_BLOCKCHAIN, toToken.network.name) if (fromDerivationIndex != null) put(ACCOUNT_DERIVATION_FROM, fromDerivationIndex.toString()) if (toDerivationIndex != null) put(ACCOUNT_DERIVATION_TO, toDerivationIndex.toString()) + put(AnalyticsParam.Key.FEE_ASSET_TYPE, feeAssetType.value) }, ), AppsFlyerIncludedEvent diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 7e59abe87e..ad6eac10b6 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -503,12 +503,19 @@ internal class SendWithSwapConfirmModel @Inject constructor( .getOrNull()?.account val toDerivationIndex = destinationAccount?.derivationIndex?.value + val feeToken = getSelectedFeeToken() + val feeAssetType = if (feeToken is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } analyticsEventHandler.send( SendWithSwapAnalyticEvents.TransactionScreenOpened( providerName = selectedProvider.name, feeType = feeType, fromToken = fromCurrency, toToken = toCurrency, + feeAssetType = feeAssetType, fromDerivationIndex = fromDerivationIndex, toDerivationIndex = toDerivationIndex, ), @@ -519,7 +526,8 @@ internal class SendWithSwapConfirmModel @Inject constructor( blockchain = fromCurrency.network.name, token = fromCurrency.symbol, feeType = feeType, - feeToken = getSelectedFeeToken().symbol, + feeToken = feeToken.symbol, + feeAssetType = feeAssetType, ), memoType = Basic.TransactionSent.MemoType.Null, ), diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 04428126e9..54c93824d2 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FR import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN @@ -103,6 +104,7 @@ sealed class SwapEvents( val sendToken: String, val receiveToken: String, val feeToken: String, + val feeAssetType: AnalyticsParam.FeeAssetType, val fromDerivationIndex: Int?, val toDerivationIndex: Int?, val referralId: String?, @@ -118,6 +120,7 @@ sealed class SwapEvents( if (fromDerivationIndex != null) put(ACCOUNT_DERIVATION_FROM, fromDerivationIndex.toString()) if (toDerivationIndex != null) put(ACCOUNT_DERIVATION_TO, toDerivationIndex.toString()) put(FEE_TOKEN, feeToken) + put(AnalyticsParam.Key.FEE_ASSET_TYPE, feeAssetType.value) putAll(getReferralParams(referralId)) }, ), AppsFlyerIncludedEvent diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index f3e444ddbb..0303bff707 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -1093,7 +1093,7 @@ internal class SwapModel @Inject constructor( } }, ) - sendSuccessEvent() + sendSwapInProgressEvent() router.replaceAll(SwapRoute.Success) } @@ -1169,7 +1169,7 @@ internal class SwapModel @Inject constructor( } } - private suspend fun sendSuccessEvent() { + private suspend fun sendSwapInProgressEvent() { val provider = dataState.selectedProvider ?: return val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return @@ -1177,6 +1177,12 @@ internal class SwapModel @Inject constructor( val fromDerivationIndex = fromSwapCurrencyStatus.account.derivationIndex?.value val toDerivationIndex = toSwapCurrencyStatus.account.derivationIndex?.value + val feeToken = getFeeToken() + val feeAssetType = if (feeToken is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } analyticsEventHandler.send( SwapEvents.SwapInProgressScreen( provider = provider, @@ -1185,7 +1191,8 @@ internal class SwapModel @Inject constructor( receiveBlockchain = toSwapCurrencyStatus.currency.network.name, sendToken = fromSwapCurrencyStatus.currency.symbol, receiveToken = toSwapCurrencyStatus.currency.symbol, - feeToken = getFeeToken().symbol, + feeToken = feeToken.symbol, + feeAssetType = feeAssetType, fromDerivationIndex = fromDerivationIndex, toDerivationIndex = toDerivationIndex, referralId = appsFlyerStore.get()?.refcode, @@ -1558,11 +1565,18 @@ internal class SwapModel @Inject constructor( } private fun sendSuccessSwapEvent(fromToken: CryptoCurrency, feeType: FeeType) { + val feeToken = getFeeToken() + val feeAssetType = if (feeToken is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val event = AnalyticsParam.TxSentFrom.Swap( blockchain = fromToken.network.name, token = fromToken.symbol, feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()), - feeToken = getFeeToken().symbol, + feeToken = feeToken.symbol, + feeAssetType = feeAssetType, ) analyticsEventHandler.send( Basic.TransactionSent( diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt index 1cbf1b303f..8cc09abf5b 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModel.kt @@ -25,6 +25,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase +import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyGetContractAddressUseCase @@ -141,59 +142,69 @@ internal class YieldSupplyApproveModel @Inject constructor( userWallet = userWallet, network = cryptoCurrency.network, ).fold( - ifLeft = { error -> - TangemLogger.e(error.toString()) - uiState.update(YieldSupplyTransactionReadyTransformer) - analyticsEventHandler.send( - YieldSupplyAnalytics.EarnErrors( - action = YieldSupplyAnalytics.Action.Approve, - errorDescription = error.getAnalyticsDescription(), - ), - ) - yieldSupplyAlertFactory.getSendTransactionErrorState( - error = error, - popBack = params.callback::onDismissClick, - onFailedTxEmailClick = { errorMessage -> - modelScope.launch(dispatchers.default) { - yieldSupplyAlertFactory.onFailedTxEmailClick( - userWallet = userWallet, - cryptoCurrency = cryptoCurrency, - errorMessage = errorMessage, - ) - } - }, - ) - params.callback.onTransactionProgress(false) - }, - ifRight = { txHash -> - yieldSupplyPendingTracker.addPending( - userWalletId = userWallet.walletId, - cryptoCurrency = cryptoCurrency, - txIds = listOf(txHash), - ) - val event = AnalyticsParam.TxSentFrom.Earning( - blockchain = cryptoCurrency.network.name, - token = cryptoCurrency.symbol, - feeType = AnalyticsParam.FeeType.Normal, - feeToken = feeCryptoCurrencyStatus.currency.symbol, - ) - analyticsEventHandler.send( - Basic.TransactionSent( - sentFrom = event, - memoType = Basic.TransactionSent.MemoType.Null, - ), - ) - analyticsEventHandler.send(YieldSupplyAnalytics.ApprovalAction( - token = cryptoCurrency.symbol, - blockchain = cryptoCurrency.network.name, - action = YieldSupplyAnalytics.Action.Approve, - )) - params.callback.onTransactionSent() - }, + ifLeft = ::onTransactionError, + ifRight = { onTransactionSuccess(it) }, ) } } + private fun onTransactionError(error: SendTransactionError) { + TangemLogger.e(error.toString()) + uiState.update(YieldSupplyTransactionReadyTransformer) + analyticsEventHandler.send( + YieldSupplyAnalytics.EarnErrors( + action = YieldSupplyAnalytics.Action.Approve, + errorDescription = error.getAnalyticsDescription(), + ), + ) + yieldSupplyAlertFactory.getSendTransactionErrorState( + error = error, + popBack = params.callback::onDismissClick, + onFailedTxEmailClick = { errorMessage -> + modelScope.launch(dispatchers.default) { + yieldSupplyAlertFactory.onFailedTxEmailClick( + userWallet = userWallet, + cryptoCurrency = cryptoCurrency, + errorMessage = errorMessage, + ) + } + }, + ) + params.callback.onTransactionProgress(false) + } + + private suspend fun onTransactionSuccess(txHash: String) { + yieldSupplyPendingTracker.addPending( + userWalletId = userWallet.walletId, + cryptoCurrency = cryptoCurrency, + txIds = listOf(txHash), + ) + val feeAssetType = if (feeCryptoCurrencyStatus.currency is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } + val event = AnalyticsParam.TxSentFrom.Earning( + blockchain = cryptoCurrency.network.name, + token = cryptoCurrency.symbol, + feeType = AnalyticsParam.FeeType.Normal, + feeToken = feeCryptoCurrencyStatus.currency.symbol, + feeAssetType = feeAssetType, + ) + analyticsEventHandler.send( + Basic.TransactionSent( + sentFrom = event, + memoType = Basic.TransactionSent.MemoType.Null, + ), + ) + analyticsEventHandler.send(YieldSupplyAnalytics.ApprovalAction( + token = cryptoCurrency.symbol, + blockchain = cryptoCurrency.network.name, + action = YieldSupplyAnalytics.Action.Approve, + )) + params.callback.onTransactionSent() + } + private fun subscribeOnCurrencyStatusUpdates() { modelScope.launch { feeCryptoCurrencyStatusFlow.update { diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt index c850cb0581..3a08b50d1e 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModel.kt @@ -17,6 +17,7 @@ import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSy import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.isHotWallet @@ -267,11 +268,18 @@ internal class YieldSupplyStartEarningModel @Inject constructor( cryptoCurrency = cryptoCurrency, yieldSupplyPendingStatus = YieldSupplyPendingStatus.Enter(txsData), ) + val feeCurrency = feeCryptoCurrencyStatusFlow.value.currency + val feeAssetType = if (feeCurrency is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val event = AnalyticsParam.TxSentFrom.Earning( blockchain = cryptoCurrency.network.name, token = cryptoCurrency.symbol, feeType = AnalyticsParam.FeeType.Normal, - feeToken = feeCryptoCurrencyStatusFlow.value.currency.symbol, + feeToken = feeCurrency.symbol, + feeAssetType = feeAssetType, ) analytics.send( YieldSupplyAnalytics.FundsEarned( diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt index 0a339baa9b..3123618250 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModel.kt @@ -195,11 +195,17 @@ internal class YieldSupplyStopEarningModel @Inject constructor( referralId = appsFlyerStore.get()?.refcode, ), ) + val feeAssetType = if (feeCryptoCurrencyStatus.currency is CryptoCurrency.Coin) { + AnalyticsParam.FeeAssetType.Coin + } else { + AnalyticsParam.FeeAssetType.Token + } val event = AnalyticsParam.TxSentFrom.Earning( blockchain = cryptoCurrency.network.name, token = cryptoCurrency.symbol, feeType = AnalyticsParam.FeeType.Normal, feeToken = feeCryptoCurrencyStatus.currency.symbol, + feeAssetType = feeAssetType, ) analytics.send( Basic.TransactionSent( From c7269fbadb126ffb1c09e3354dd70142d8993c67 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 May 2026 12:37:35 +0400 Subject: [PATCH 200/206] Updated on 2026-08-14 --- .../java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 09b5c11731..e3cbf8b44f 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -1950,6 +1950,7 @@ internal class SwapInteractorImpl @Inject constructor( private fun Fee.increaseGasLimitBy(percentage: Int): Fee { if (this !is Fee.Ethereum) return this val gasLimit = this.gasLimit + if (gasLimit == BigInteger.ZERO) return this val increasedGasPrice = this.amount.value?.movePointRight(this.amount.decimals) ?.divide(gasLimit.toBigDecimal(), RoundingMode.HALF_UP) val increasedGasLimit = gasLimit.multiply(percentage.toBigInteger()).divide(hundredPercent) From 305758f4be1e3aa747ef05007f059c8f0cb928d5 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 May 2026 14:02:58 +0200 Subject: [PATCH 201/206] Updated on 2026-08-14 --- .../model/AddToPortfolioModel.kt | 6 +- .../components/earn/DefaultEarnComponent.kt | 18 ++-- .../components/feed/DefaultFeedComponent.kt | 7 +- .../features/feed/model/earn/EarnModel.kt | 97 +++++++++---------- .../feed/model/feed/FeedComponentModel.kt | 71 ++++++++------ 5 files changed, 102 insertions(+), 97 deletions(-) diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt index 0aaee52e02..d6fc2e1ff3 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/addtoportfolio/model/AddToPortfolioModel.kt @@ -136,12 +136,12 @@ internal class AddToPortfolioModel @Inject constructor( // but its call AddToPortfolioManager.onAddedTokenClick callback !isAvailableToAdd -> { val singleNetwork = portfolio.account.addedMarketNetworks.first() - callbackDelegate.onNetworkSelected(singleNetwork) + callbackDelegate.onNetworkSelected.send(singleNetwork) } // force select a network, triggers [selectedNetwork] isSingleAvailableNetwork -> { val singleNetwork = portfolio.account.availableToAddNetworks.first() - callbackDelegate.onNetworkSelected(singleNetwork) + callbackDelegate.onNetworkSelected.send(singleNetwork) } // it's important to control root screen, UI depends on it(close/arrow icon) isSinglePortfolio -> navigation.replaceAll(routeToNetworkSelector(portfolio)) @@ -352,7 +352,7 @@ internal class AddToPortfolioModel @Inject constructor( val isSingleAvailableNetwork = portfolio.account.isSingleNetwork if (isSingleAvailableNetwork) { val singleNetwork = portfolio.account.availableToAddNetworks.first() - callbackDelegate.onNetworkSelected(singleNetwork) + callbackDelegate.onNetworkSelected.send(singleNetwork) } else { navigation.pushNew(routeToNetworkSelector(portfolio)) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index 9e4094404f..17381adf81 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -33,15 +33,14 @@ import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.domain.models.earn.PreselectedEarnType +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.feed.components.feed.FeedBottomSheetRoute -import kotlinx.serialization.Serializable import com.tangem.features.feed.model.earn.EarnModel -import com.tangem.features.feed.model.earn.analytics.EarnSource import com.tangem.features.feed.ui.components.FeedSearchBar import com.tangem.features.feed.ui.earn.EarnContent import dev.chrisbanes.haze.HazeProgressive +import kotlinx.serialization.Serializable internal class DefaultEarnComponent( appComponentContext: AppComponentContext, @@ -130,17 +129,12 @@ internal class DefaultEarnComponent( componentContext: ComponentContext, ): ComposableBottomSheetComponent = when (config) { is FeedBottomSheetRoute.AddToPortfolio -> { + val manager = checkNotNull(earnModel.currentAddToPortfolioManager) { + "currentAddToPortfolioManager must be set before activating AddToPortfolio slot" + } addToPortfolioComponentFactory.create( context = childByContext(componentContext), - params = AddToPortfolioComponent.Params( - addToPortfolioManager = when { - config.source == EarnSource.BEST_OPPORTUNITIES_SOURCE.value -> - earnModel.addBestOpportunitiesPortfolioManager - config.source == EarnSource.MOSTLY_USED_SOURCE.value -> - earnModel.addMostlyUsedPortfolioManager - else -> error("Unknown source: ${config.source}") - }, - ), + params = AddToPortfolioComponent.Params(addToPortfolioManager = manager), ) } is FeedBottomSheetRoute.NetworkFilter -> EarnNetworkFilterComponent( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt index 21ebee8140..f83d390045 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/feed/DefaultFeedComponent.kt @@ -98,11 +98,12 @@ internal class DefaultFeedComponent( componentContext: ComponentContext, ): ComposableBottomSheetComponent = when (config) { is FeedBottomSheetRoute.AddToPortfolio -> { + val manager = checkNotNull(feedComponentModel.currentAddToPortfolioManager) { + "currentAddToPortfolioManager must be set before activating AddToPortfolio slot" + } addToPortfolioComponentFactory.create( context = childByContext(componentContext), - params = AddToPortfolioComponent.Params( - addToPortfolioManager = feedComponentModel.addToPortfolioManager, - ), + params = AddToPortfolioComponent.Params(addToPortfolioManager = manager), ) } is FeedBottomSheetRoute.NetworkFilter -> EmptyComposableBottomSheetComponent diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index ae1280b2cf..bd7e22b2e4 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -22,8 +22,9 @@ import com.tangem.domain.markets.TokenMarketInfo import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnNetworks import com.tangem.domain.models.earn.EarnTokenWithCurrency -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager import com.tangem.domain.models.earn.PreselectedEarnType +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams.Companion.CategoryEarn import com.tangem.features.feed.components.earn.DefaultEarnComponent import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent import com.tangem.features.feed.components.earn.EarnTypeFilterComponent @@ -44,8 +45,8 @@ import com.tangem.features.feed.ui.earn.state.EarnFilterTypeUM import com.tangem.features.feed.ui.earn.state.EarnUM import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import javax.inject.Inject @Stable @@ -85,24 +86,13 @@ internal class EarnModel @Inject constructor( dispatchers = dispatchers, ) - val addBestOpportunitiesPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( - scope = modelScope, - settings = AddToPortfolioManager.Settings.Earn, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = EarnSource.BEST_OPPORTUNITIES_SOURCE.value), - ).apply { - updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) - } - - val addMostlyUsedPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( - scope = modelScope, - settings = AddToPortfolioManager.Settings.Earn, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = EarnSource.MOSTLY_USED_SOURCE.value), - ).apply { - updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) - } - val bottomSheetNavigation: SlotNavigation = SlotNavigation() + var currentAddToPortfolioManager: AddToPortfolioManager? = null + private set + + private var currentAddToPortfolioManagerScope: CoroutineScope? = null + val state: StateFlow get() = stateController.uiState @@ -114,30 +104,6 @@ internal class EarnModel @Inject constructor( subscribeOnNetworks() subscribeOnBatchFlow() subscribeToMostlyUsed() - - addBestOpportunitiesPortfolioManager.onDismiss.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .launchIn(modelScope) - addBestOpportunitiesPortfolioManager.onSuccessAdded.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) - addBestOpportunitiesPortfolioManager.onAddedTokenClick.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) - - addMostlyUsedPortfolioManager.onDismiss.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .launchIn(modelScope) - addMostlyUsedPortfolioManager.onSuccessAdded.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) - addMostlyUsedPortfolioManager.onAddedTokenClick.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) } private fun openCurrencyDetails(result: AddToPortfolioManager.Result) { @@ -354,17 +320,44 @@ internal class EarnModel @Inject constructor( contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, decimalCount = earnTokenWithCurrency.earnToken.decimalCount, ) - when (source) { - EarnSource.BEST_OPPORTUNITIES_SOURCE -> addBestOpportunitiesPortfolioManager.apply { - setTokenParams(token) - setTokenNetworks(listOf(network)) - } - EarnSource.MOSTLY_USED_SOURCE -> addMostlyUsedPortfolioManager.apply { - setTokenParams(token) - setTokenNetworks(listOf(network)) - } + val manager = createAddToPortfolioManager(source = source).apply { + setTokenParams(token) + setTokenNetworks(listOf(network)) } - bottomSheetNavigation.activate(FeedBottomSheetRoute.AddToPortfolio(source.value)) + currentAddToPortfolioManager = manager + // Drop the slot through null so the same-source repeat click still recreates the child. + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate(FeedBottomSheetRoute.AddToPortfolio(source = source.value)) + } + + private fun createAddToPortfolioManager(source: EarnSource): AddToPortfolioManager { + currentAddToPortfolioManagerScope?.cancel() + val managerScope = CoroutineScope( + modelScope.coroutineContext + SupervisorJob(modelScope.coroutineContext.job), + ) + currentAddToPortfolioManagerScope = managerScope + + val manager = addToPortfolioManagerFactory.create( + scope = managerScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams(source = source.value, category = CategoryEarn), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) + } + + manager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(managerScope) + manager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(managerScope) + manager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(managerScope) + + return manager } private fun onTypeFilterOptionSelected(type: EarnFilterType) { diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index 7a7c71ccc5..a0cb8e55db 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -41,10 +41,8 @@ import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentHashMap -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch import org.joda.time.DateTime import org.joda.time.DateTimeZone import javax.inject.Inject @@ -62,7 +60,7 @@ internal class FeedComponentModel @Inject constructor( private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase, private val appRouter: AppRouter, private val designFeatureToggles: DesignFeatureToggles, - addToPortfolioManagerFactory: AddToPortfolioManager.Factory, + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, paramsContainer: ParamsContainer, @@ -87,16 +85,13 @@ internal class FeedComponentModel @Inject constructor( dispatchers = dispatchers, ) - val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create( - scope = modelScope, - settings = AddToPortfolioManager.Settings.Earn, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = AnalyticsParam.ScreensSources.Markets.value), - ).apply { - updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) - } - val bottomSheetNavigation: SlotNavigation = SlotNavigation() + var currentAddToPortfolioManager: AddToPortfolioManager? = null + private set + + private var currentAddToPortfolioManagerScope: CoroutineScope? = null + val state: StateFlow get() = stateController.uiState @@ -110,18 +105,6 @@ internal class FeedComponentModel @Inject constructor( fetchCharts() subscribeOnCurrencyUpdate() subscribeOnDataState() - - addToPortfolioManager.onDismiss.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .launchIn(modelScope) - addToPortfolioManager.onSuccessAdded.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) - addToPortfolioManager.onAddedTokenClick.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .onEach(::openCurrencyDetails) - .launchIn(modelScope) } private fun openCurrencyDetails(result: AddToPortfolioManager.Result) { @@ -431,12 +414,46 @@ internal class FeedComponentModel @Inject constructor( contractAddress = earnTokenWithCurrency.earnToken.tokenAddress, decimalCount = earnTokenWithCurrency.earnToken.decimalCount, ) - val route = FeedBottomSheetRoute.AddToPortfolio(AnalyticsParam.ScreensSources.Markets.value) - addToPortfolioManager.apply { + val manager = createAddToPortfolioManager().apply { setTokenParams(token) setTokenNetworks(listOf(network)) } - bottomSheetNavigation.activate(route) + currentAddToPortfolioManager = manager + // Drop the slot through null so the same-source repeat click still recreates the child. + bottomSheetNavigation.dismiss() + bottomSheetNavigation.activate(FeedBottomSheetRoute.AddToPortfolio(AnalyticsParam.ScreensSources.Markets.value)) + } + + private fun createAddToPortfolioManager(): AddToPortfolioManager { + currentAddToPortfolioManagerScope?.cancel() + val managerScope = CoroutineScope( + modelScope.coroutineContext + SupervisorJob(modelScope.coroutineContext.job), + ) + currentAddToPortfolioManagerScope = managerScope + + val manager = addToPortfolioManagerFactory.create( + scope = managerScope, + settings = AddToPortfolioManager.Settings.Earn, + analyticsParams = AddToPortfolioManager.AnalyticsParams( + source = AnalyticsParam.ScreensSources.Markets.value, + ), + ).apply { + updateLaunchMode(AddToPortfolioManager.LaunchMode.Preselected) + } + + manager.onDismiss.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .launchIn(managerScope) + manager.onSuccessAdded.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(managerScope) + manager.onAddedTokenClick.receiveAsFlow() + .onEach { bottomSheetNavigation.dismiss() } + .onEach(::openCurrencyDetails) + .launchIn(managerScope) + + return manager } private fun handleEarnPageOpenClicked() { From fa7926ebf9a23656efed52af629d487c199000f3 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 May 2026 17:07:34 +0300 Subject: [PATCH 202/206] Updated on 2026-08-14 --- core/res/src/main/res/values-de/strings.xml | 169 +++++++++++++++--- core/res/src/main/res/values-es/strings.xml | 24 ++- core/res/src/main/res/values-fr/strings.xml | 16 ++ core/res/src/main/res/values-it/strings.xml | 2 + core/res/src/main/res/values-ja/strings.xml | 89 ++++++++- .../src/main/res/values-pt-rBR/strings.xml | 82 ++++++++- core/res/src/main/res/values-ru/strings.xml | 45 ++++- .../src/main/res/values-uk-rUA/strings.xml | 29 +++ .../src/main/res/values-zh-rCN/strings.xml | 90 ++++++++++ .../src/main/res/values-zh-rTW/strings.xml | 2 + core/res/src/main/res/values/strings.xml | 100 ++++++++++- 11 files changed, 608 insertions(+), 40 deletions(-) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index 34d53f84b5..786150a81b 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -80,6 +80,9 @@ Token hinzufügen Wähle den Token aus, den Du erhalten möchtest Wähle den Token, den Du tauschen möchtest + Guthaben hinzufügen + Tauschen + Übertragung Zum Portfolio hinzufügen Token hinzufügen Sortieren und Gruppieren @@ -87,6 +90,10 @@ Netzwerk wählen Token anlegen Token verwalten + Kreditkarte oder Bankkonto + Teile deine Adresse oder dein QR-Code + Zwische deinen Portfolios + Empfangen Sende nur %1$s ( %2$s ) vom %3$s -Netzwerk an diese Adresse. Die Verwendung anderer Token und Netzwerke kann zum Verlust von Geldern führen. Standard Altbestand @@ -136,7 +143,7 @@ Du hast Deine Wallet erfolgreich gesichert. Diese Wörter können bei Verlust nicht wiederhergestellt werden. Bewahre diese an einem sicheren Ort auf. Sicherung abgeschlossen - Deine geheime Wiederherstellungsphrase ist eine feste Folge von %s zufälligen Wörtern, mit denen Du auf Deiner Wallet zugreifefst und sie wiederherstellen kannst. + Deine geheime Wiederherstellungsphrase ist eine feste Folge von %s zufälligen Wörtern, mit denen Du auf Deiner Wallet zugreifst und sie wiederherstellen kannst. Diese Worte sind unwiederbringlich verloren. Bewahre diese gut auf. Sicher aufbewahren Speicher diese %s Wörter an einem sicheren Ort und gebe diese niemals an andere weiter. @@ -217,8 +224,10 @@ Zugang verweigert Konto Konten + %s fehlgeschlagen Aktivieren Hinzufügen + Guthaben hinzufügen Zum Portfolio hinzufügen Token hinzufügen Token hinzufügen @@ -232,6 +241,8 @@ Anwenden Genehmigung Genehmigen + Genehmigt + Genehmigen Achtung Verfügbare Netzwerke Sicherungskopie @@ -274,14 +285,20 @@ Tag Tage + + %d Tag zuvor + %d Tage zuvor + Entfernen Deaktivieren Deaktiviert + Deaktivieren Trennen Erledigt Bearbeiten Aktivieren Aktiviert + Aktivieren Fehler Aufladegebühr Umtausch @@ -308,11 +325,16 @@ Ausblenden Halten bis %s Stunde + + Stunde + Stunde + %dStunde her %dStunden her Importieren + in In Arbeit Unzureichende Mittel Später @@ -327,6 +349,7 @@ %dMinuten her Monat + Mehr Netzgebühr Der überwiesene Betrag wird um %1$s (%2$s) gekürzt, um die gewählte Gebührenhöhe zu decken. @@ -356,6 +379,8 @@ %1$s — %2$s Weiterlesen Empfangen + Erhalten + Empfang Empfohlen Ablehnen Neu laden @@ -372,7 +397,10 @@ Aktion auswählen Verkaufen Senden + Senden: Absenden der Transaktion fehlgeschlagen + Senden + Gesendet Der Server ist nicht verfügbar. Bitte versuche es später erneut. Teilen Link teilen @@ -383,6 +411,7 @@ Überspringen Etwas ist schiefgelaufen. Staken + Einsatz Staking Start Einreichen @@ -390,6 +419,8 @@ Unterstützung Unterstützte Netzwerke Tauschen + Tauschen + Tauschen Tangem Tangem Wallet Tippen und halten @@ -398,6 +429,7 @@ An Zu %s Heute + Token Zu sendendes Token %d Token @@ -407,6 +439,8 @@ Transaktionsstatus Transaktionen Überweisung + Übertragen + Bitte versuche es später noch einmal. Die Daten konnten nicht geladen werden… Ich verstehe Ich verstehe, fahre bitte fort. @@ -416,10 +450,12 @@ Staking beenden Aufgrund der Beschränkungen von %1$s können nur %2$d UTXOs in eine einzige Transaktion passen. Das bedeutet, dass du nur %3$s oder weniger senden kannst. Du musst den Betrag reduzieren. Wert kopiert + Abstimmen Meine Wallet Warnung Woche mit + Überweisen Ja Ertragsmodus Vertragsadresse kopiert! @@ -505,6 +541,10 @@ Nicht verfügbar Wir können im Moment keine Verbindung zum Provider herstellen. Bitte versuchen Sie es später noch einmal. Der Dienst ist nicht verfügbar. Bitte versuchen Sie es erneut. + Es wurden Gelder an zusätzlichen Adressen gefunden. Aktivieren deine Dynamische Adressen, um auf diese zuzugreifen. + Auf weiteren Adressen gefundene Gelder + Dynamische Adresse + Die Verwaltung dynamischer Adressen wird verfügbar sein, sobald die ausstehenden Transaktionen im Netzwerk eingegangen sind. %@ ist abgeschlossen Beste Gelegenheiten Filter löschen Die Liste ist vorübergehend leer, da sie gerade aktualisiert wird. Schauen Sie in Kürze wieder rein. @@ -515,7 +555,7 @@ Netzwerke Meist verwendet Keine Ergebnisse - Verdienen + Verdiene Hallo Support-Team, ich habe einen Fehler mit dem Code %s festgestellt. WalletConnect-Fehler Du hast eine Karte oder Ring aus einer anderen Wallet verwendet. Tippe auf die Karte oder Ring, die dieser Wallet zugeordnet ist. @@ -577,12 +617,14 @@ Bester Preis Warnliste der FCA Der Festzins ist nicht verfügbar + Anbieter für Tausch Beste Wahl Anbieter in FCA-Warnliste Verfügbar bis zu %s Erhältlich bei %s Für dieses Paar nicht verfügbar Erlaubnis erforderlich + Genehmigung erforderlich Empfohlen Gekauft %s Kauf %s @@ -629,6 +671,7 @@ Die Genehmigungsfunktion ist erforderlich, um einer anderen Adresse die Berechtigung zur Verwendung einer bestimmten Menge Ihrer Token zu erteilen. Standardmäßig können Smart Contracts nicht auf deine Token zugreifen, es sei denn, du stimmen zu. Indem du deine Token \"freischaltest\", autorisierst du den StakeKit Smart Contract, sie zu verwenden. Die Miner des Netzwerks erhalten eine Gasgebühr (von dir bezahlt), um diese Aktion in der Blockchain aufzuzeichnen. Du kannst deine Token einsetzen, nachdem du die Genehmigung erteilt hast. Um fortzufahren, musst du Polygon Smart Contract erlauben, deine %s zu verwenden Um fortzufahren, erteile %1s Smart Contracts die Berechtigung, dein zu %2s verwenden. + Dezentrale Börsen benötigen eine Berechtigung, um mit Ihrer Wallet zu interagieren. %1s Erlaubnis erteilen Unbegrenzt Die Adressen werden direkt auf Deiner Tangem-Hardware-Wallet generiert – sofort einsatzbereit und vollständig geschützt. @@ -656,6 +699,8 @@ Hält Deine Kryptowährungen sicher und offline. Schlank wie eine Kreditkarte, sicherer als ein Banktresor. Wenn dies der Fall ist, musst Du von vorne beginnen. Vorhandene Wallet über Google Drive-Backup wiederherstellen + Wir arbeiten an einer Google Drive-Datensicherung, um die Wiederherstellung der Wallet noch einfacher zu gestalten. + Google Drive-Sicherung kommt bald Google Drive-Backup Erstelle eine neue, sichere Wallet und übertrage Deine Gelder, um zusätzlichen Schutz zu gewährleisten. Neue Wallet erstellen @@ -713,7 +758,7 @@ Schlüsselmigration Gerät scannen Upgrade starten - Du stehst kurz vor dem Upgrade auf unsere Hardware-Wallet. Damit werden Deine Vermögenswerte sicher in Offline-Speichern aufbewahrt. + Du stehst kurz vor dem Upgrade auf unsere Hardware-Wallet. Deine Vermögenswerte werden darin sicher im Offline-Speicher aufbewahrt. Tangem Wallet Upgrade auf Hardware Wallet Schütze Deine Kryptowährungen mit Tangems erstklassiger Hardware-Wallet. @@ -775,6 +820,7 @@ Dieses Asset ist für dieses Wallet nicht verfügbar Hinzufügen APY %s + Marktpreis Mein Portfolio Markt Verdiene Geld mit Tangem @@ -787,10 +833,11 @@ Keine Daten **Hinzufügen zu Ihrem Portfolio**, um mit dem Kauf, Tausch oder Erhalt dieses Vermögenswerts zu beginnen Zum Portfolio hinzufügen + Dein Portfolio Marktimpuls Schnelle Aktionen Alles löschen - Markt durchsuchen + Token suchen Neueste In Ihrem Portfolio Ergebnis @@ -816,6 +863,7 @@ Ertragsmodus Staking ist der einfachste Weg, um Belohnungen für Deine Kryptowährung zu erhalten. %s Verdiene bis zu %s APY + in einem anderen Netzwerk oder Konto Token hinzugefügt Über %s @@ -980,7 +1028,7 @@ Trage dich in die Warteliste ein und erhalte eine Zahlungskarte, die es so noch nie gab. Tangem Visa Card Bedingungen - Zahlen Sie mindestens $100 ein, halten Sie den Betrag 30 Tage und erhalten Sie $10. + Zahle mindestens $100 ein, halten den Betrag 30 Tage und erhalte $10. Yield-Mode-Kampagne Du musst einen einzigen Zugangscode einrichten, um alle deine Geräte zu schützen Schützen @@ -997,6 +1045,11 @@ Bitte wiederhole den Vorgang. Die Karte oder Ring wird auf Werkseinstellungen zurückgesetzt. Aktivierungsfehler Token hinzufügen + + Adressen synchronisieren, um eine Adresse für das Netzwerk %d zu erhalten + Adressen synchronisieren, um eine Adresse für das Netzwerk %d zu erhalten + + Synchronisiere dein Wallet Du hast eine Backup-Karte oder einen Backup-Ring hinzugefügt. Nach Abschluss des Backup-Vorgangs kannst Du keine weiteren Backup-Geräte hinzufügen. Wenn Du eine weitere Karte oder einen weiteren Ring hast, fügen diesen dem Backup jetzt hinzu. Möchtest Du den Backup-Vorgang fortsetzen? Der Sicherungsvorgang ist teilweise abgeschlossen. Du kannst ihn jetzt nicht beenden. Eine Passphrase ist eine optionale Sicherheitsfunktion, die Deiner Wiederherstellungsphrase ein Wort oder eine Phrase hinzufügt und so einen neuen Satz von Wallet-Adressen für zusätzlichen Schutz erstellt. @@ -1018,6 +1071,7 @@ Andere Optionen Deine Schlüssel(private-keys) werden sicher im Inneren der Karte oder Ring generiert. Es gibt keine Seed-Phrase, d. h. niemand kann sie exportieren oder stehlen. Schlüssel anonym generieren + Indem Sie fortfahren, stimmen Sie den folgenden Bedingungen zu: Deine Karte oder Ring ist aktiviert und einsatzbereit Erfolgreich! Deine Wallet ist eingerichtet und einsatzbereit! @@ -1026,7 +1080,9 @@ Erste Schritte Für die Karte oder Ring, die du hinzufügen möchtest, wurde bereits eine andere Wallets erstellt. Wenn du Guthaben auf dieser Wallets hast, hebe es bitte ab, setze diese Karte oder Ring zurück und füge sie als Backup hinzu. Sicher deine Wallet + Biometrische Daten nutzen Backups anlegen + Letzter Schritt Biometrische Daten Lese mehr über die Seed-Phrase @@ -1093,12 +1149,20 @@ Diese Transaktion wurde bereits verarbeitet. Es sind keine weiteren Maßnahmen erforderlich. Die besten Preise erzielen... Sofort + Die Verifizierung ist kostenlos und dauert in der Regel 1-2 Minuten + Tangem hat keinen Zugriff auf Ihre Identitätsinformationen; Sie teilen Daten direkt mit dem regulierten Anbieter. + Die Verifizierung schaltet den vollen Zugang zu zukünftigen Transaktionen mit diesem Anbieter frei + Wählen Sie eine andere Methode + Zur Einhaltung der örtlichen Vorschriften verlangt %@ eine Identitätsprüfung. + Identitätsprüfung durch den Zahlungsanbieter erforderlich + Verifizieren + Was ist wichtig? Durch die Nutzung der Onramp-Funktionalität stimmst Du den %1$s und %2$s des Anbieters zu. Der Service wird von einem externen Anbieter bereitgestellt. \n Tangem übernimmt keine Verantwortung. Der Kaufbetrag sollte nicht höher sein als %s Der zu kaufende Betrag muss mindestens %s betragen Keine verfügbaren Anbieter für diese Währung - Schnellste + Schnellste Bearbeitung Bezahlen mit Zahlungsmethode Verfügbar bis zu %s @@ -1133,6 +1197,8 @@ über %s Du zahlst Gruppe erstellen + Netzwerke auswählen + Nach Kontostand sortieren Nach Guthaben Token organisieren Gruppe löschen @@ -1156,6 +1222,13 @@ Keine unterstützten Token gefunden Dieser QR-Code enthält Parameter, die nicht erkannt werden: %s. Einige Zahlungsdetails können verloren gehen, wenn Sie fortfahren. Unbekannte Parameter + Kreditkarte oder Bankkonto + Teilen deine Adresse oder dein QR-Code + Sicherer Verkauf von Kryptowährungen + An eine andere Wallet senden + Zwischen deinen Portfolios + Andere + Schnell aufladen Kein Memo erforderlich %1$s ( %2$s ) im %3$s Netzwerk %1$s im %2$s Netzwerk @@ -1402,6 +1475,7 @@ Der Zeitraum, den du nach der Beantragung der Abhebung von Geldern aus dem Staking warten musst, bevor die Token verfügbar werden. Aufwärmphase Die zugewiesene Zeit für die Aktivierung der Teilnahme am Staking. + Staking aktiviert Zurzeit sind keine Validierer verfügbar. Bitte versuche es später noch einmal. Staking nicht verfügbar Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Du die Verwendung Deines Tokens für das Staking genehmigst. @@ -1512,23 +1586,34 @@ Zum Fortfahren ist eine eingehende Transaktion von mindestens %1$s erforderlich Unzureichende Mittel Durch die Genehmigung erlaubst Du dem Smart Contract, Deine Token in zukünftigen Transaktionen zu verwenden. + Detaillierter Modus Fester Zinssatz Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Sie die Verwendung Ihres Tokens für den Swap genehmigen. + Tausch läuft Tausche mehr Token zu besseren Kursen direkt in deiner Brieftasche. Neuer Swap-Anbieter verfügbar! Suchen Sie etwas anderes?\n Versuchen Sie es mit der Suche oder erkunden Sie eine andere Kryptowährung! - Suchen Sie nach einem beliebigen Token, auch wenn es noch nicht in Ihrer Liste ist. + Suche nach einem beliebigen Token, auch wenn es noch nicht in deiner Liste ist. Nutzen Sie die Suche, um zu finden, was Sie benötigen. + Einfacher Modus Vertraue auf den rund um die Uhr verfügbaren Support bei allen Problemen Immer für Dich da Mehrere vertrauenswürdige Anbieter an einem Ort – tausche mühelos alle Vermögenswerte in Deiner Wallet + Tauschen Sie Kryptowährungen direkt in Tangem\nkeine zusätzlichen Überweisungen\nkeine Verschiebung von Geldern zu Börsen Tausche mit uns + Tausche innerhalb deiner Wallet Keine Fummeleien, keine Umsätze, keine blinden Flecken – Deine Transaktion ist immer geschützt + Tauschvorgänge werden über vertrauenswürdige Anbieter abgewickelt. Deine Schlüssel verbleiben jederzeit in deiner Tangem-Wallet. Klar. Transparent. Selbstverwahrung. Undurchdringliche Verteidigung + Du behältst die Kontrolle Maximiere Deine Wert mit Tarifen aus einem breiten Netzwerk vertrauenswürdiger Anbieter und wähle immer den Besten aus + Tangem vergleicht mehrere Anbieter, sowohl DEX als auch CEX. Der beste Kurs wird automatisch ausgewählt. Bevorzugst Du einen anderen Anbieter? Dann kannst du ihn manuell auswählen. Unschlagbare Preise + Bester verfügbarer Preis Problemlos und intuitiv, sodass Deine Token mit nur wenigen Handgriffen getauscht werden können + Tauschen Token über viele Netzwerke und Tausende von Token hinweg 0% Gebühr für Stablecoin-zu-Stablecoin-swaps Einfach bequem + 90+ Blockchains\n16.000+ Vermögenswerte Tausch über Anbieter Dein Vermögen Der Betrag umfasst:\n- Gebühr des Dienstanbieters\n- Netzgebühr für die Rücksendung von %s von der Vermittlungsstelle an die Adresse des Nutzers. @@ -1539,7 +1624,9 @@ Alle dezentralen Börsen benötigen Genehmigungen, um zu verhindern, dass intelligente Verträge ohne Ihre Erlaubnis auf Ihre Geldbörse zugreifen. Smart Contracts können nicht auf Ihre Token zugreifen, wenn Sie nicht zustimmen. Indem Sie Ihre Token \"freischalten\", ermächtigen Sie den 1-Zoll-Smart-Contract, sie auszugeben. Die Miner des Netzwerks erhalten eine (von Ihnen bezahlte) Gasgebühr, um diese Aktion in der Blockchain aufzuzeichnen. Sie können Ihre Token tauschen, nachdem Sie Ihre Zustimmung gegeben haben. Genehmigen Fehler bei der Gebührenschätzung. Bitte sende dein Feedback an den Support. + Du sendest vom Du wechselst + De sendest Der Tausch dieser Menge ausgewählter Token hat erhebliche Auswirkungen auf den Preis und verringert dein Ergebnis. Aufgrund geringer Liquidität erhalten Sie möglicherweise deutlich weniger. Versuchen Sie es mit einem kleineren Betrag oder einem anderen Anbieter. Hoher Einfluss auf den Preis @@ -1548,11 +1635,14 @@ Erlaubnis erteilen Tauschen Tauschen... + Zu erhaltender Betrag Du erhältst Token auswählen Nicht verfügbar Nicht genug Liquidität für diesen Handel. Reduzieren Sie den Betrag oder wählen Sie einen anderen Anbieter. Handel zu groß + Übertragung + Übertragung Wir freuen uns über Ihr Feedback Tangem Pay jetzt in der Beta Karte kann nicht umbenannt werden @@ -1603,7 +1693,7 @@ PIN-Code erstellt CVC Daten konnten nicht geladen werden. Versuche es später noch einmal. - Ablaufdatum + Ablauf Karte einfrieren Details ausblenden Verstecken @@ -1629,11 +1719,12 @@ Karte neu ausstellen Es sind nur Buchstaben und Zahlen erlaubt Ungültige Zeichen + Kartenname Aufdecken Details anzeigen Tausche beliebige Vermögenswerte in Deinem Portfolio für deine Karte. Kartendetails - Bitte versuchen Sie es später noch einmal. + Bitte versuche es später noch einmal. Karte entsperren Komm zurück zur App, falls du es vergisst. Dein PIN-Code @@ -1641,6 +1732,7 @@ Auszahlung derzeit nicht möglich Sie können keinen Tausch oder eine neue Auszahlung starten, bis die aktuelle abgeschlossen ist. Auszahlung läuft + Kartenname Limit festlegen ab %s Das Limit konnte nicht festgelegt werden. Bitte versuchen Sie es erneut. Ändern @@ -1665,8 +1757,15 @@ Holen Sie sich Ihre kostenlose virtuelle Tangem Visa-Karte Tangem Pay erhalten Zum Support + Es generiert einen neuen Satz Kartendaten. + Ausgabegebühr + Einzahlung von USDC auf das Zahlungskonto zur Deckung der Ausstellungsgebühr + Gebühr kann nicht gedeckt werden + Eine zusätzliche Karte ausstellen? + Karte ausstellen Es dauert in der Regel bis zu 15 Minuten. Einrichtung Ihrer Tangem-Karte + Ausstellung einer neuen digitalen Karte Ausstellung Deiner Karte Die Karte wird in der Regel innerhalb von 5 Minuten automatisch ausgestellt. In seltenen Fällen, wenn eine manuelle Überprüfung erforderlich ist, kann die Ausstellung bis zu 48 Stunden dauern. Tangem Pay @@ -1683,6 +1782,8 @@ KYC-Sperre ausblenden Leider konnten wir Ihre Identität nicht verifizieren. + Du kannst bis zu 3 Karten haben. Lösche eine, um eine neue Karte hinzuzufügen. + Maximale Anzahl ausgegebener Karten Holen Sie sich Ihre kostenlose virtuelle Tangem Visa-Karte Nutzen Sie USDC für alltägliche Zahlungen Karte erhalten @@ -1692,6 +1793,8 @@ Zahlen Sie genau das, was Sie sehen Ein separates Zahlungskonto wird erstellt, ohne Ihre Adressen und Vermögenswerte offenzulegen Unerreichte Privatsphäre + Verknüpfen Sie eine Zahlungskarte + Wir richten eine Wallet ein. Erhalten Sie Ihre kostenlose Tangem Pay Card in wenigen Minuten Bezahlen mit Zahlungskonto @@ -1712,6 +1815,7 @@ Daten können derzeit nicht angezeigt werden, Kartenzahlungen funktionieren jedoch weiterhin. Satz \nPIN-Code Karte deaktiviert + Ersetzen deine Karte Sitzung abgelaufen Zugang wiederherstellen Nutzen Sie USDC für alltägliche Zahlungen @@ -1741,6 +1845,7 @@ Die Genehmigung wurde widerrufen. Dein Guthaben befindet sich weiterhin im Ertragsmodus. Um Aktionen durchzuführen, wechsel bitte in den Ertragsmodus und erteilen die Berechtigung erneut. Verfügbares Guthaben Gesamtsaldo + Bis zu %s effektiver Jahreszins Generiere XPUB Ausblenden Du bist dabei, dieses Token vom Hauptbildschirm auszublenden. Du kannst es jederzeit über die Seite „Token verwalten“ wieder hinzufügen. @@ -1761,23 +1866,33 @@ Tausche diesen Token gegen einen anderen zu %1$s Servicegebühren von Februar %2$s-%3$s. Tausche mit Changelly, %s Gebühren Jetzt tauschen - Hot Krypto 🔥 + Markttrend 🔥 Nicht verfügbar zum Kauf Nicht zum Verkauf verfügbar Nicht verfügbar für Tausch von %s Nicht zum Tausch verfügbar + Belohnung einfordern Vertrag: %s + Deaktivierung des Ertragsmodus + Verdient aus dem Einsatz Du hast noch keine Transaktionen Der Transaktionsverlauf konnte nicht geladen werden.\nKlicke auf die Schaltfläche Neu laden, um die Informationen zu aktualisieren. + aus: %%image%% %s Mehrere Adressen Die Transaktionshistorie wird für diese Blockchain derzeit nicht verfügbar. Aber keine Sorge, wir arbeiten daran! In der Zwischenzeit kannst du es im Explorer überprüfen. Operation + Ausstehend + Belohnungen neu stecken + Belohnungen + Staking-Belohnungen + zu: %%image%% %s für: %s von: %s zu: %s Validierer: %s Benachrichtigungen sind aktiviert, funktionieren aber erst, wenn Du Benachrichtigungen in Deinen Geräteeinstellungen zulässt. Transaktionsbenachrichtigungen + Transfer läuft Minimum %s Der Mindesttransaktionsbetrag beträgt %1$s. Die Netzwerkgebühren für beliebte Token im Tron-Netzwerk können höher sein. Das Staking von TRX kann helfen, die Transaktionskosten zu senken. @@ -1809,7 +1924,9 @@ Genehmigung für Transaktionen anfordern Sei der Erste, der von neuen Aktionen erfährt Frühzeitiger Zugriff auf neue Funktionen und exklusive Angebote. + Preisbenachrichtigungen, Produktneuheiten und exklusive Angebote Updates zu Funktionen und Neuigkeiten + Angebote & Updates Möchtest du Push-Benachrichtigungen verwenden? Aktiviere Push-Benachrichtigungen und wir benachrichtigen Dich sofort, wenn Gelder eintreffen Verpasse keine Transaktion @@ -2051,6 +2168,10 @@ Verwende deine Karte oder Ring, um eine Adresse für das %d-Netz zu erhalten Verwende deine Karte oder Ring, um mehrere Adressen für die %d-Netzwerke zu erhalten + + Adressen synchronisieren, um eine Adresse für das Netzwerk %d zu erhalten + Adressen synchronisieren, um eine Adresse für das Netzwerk %d zu erhalten + Einige Adressen fehlen Das Netzwerk ist derzeit nicht erreichbar. Bitte versuchen Sie es später erneut. Netzwerk ist nicht erreichbar @@ -2217,6 +2338,7 @@ Die Gebühr wird abgezogen und Dein Vermögen wird erneut verliehen. Um weiterhin Geld verdienen zu können, ist eine Genehmigung erforderlich. Genehmigung bestätigen + Durchschnittlicher Jahreszins %1$s%% Deine Gelder werden derzeit dem Aave-Protokoll bereitgestellt, Du kannst sie jedoch jederzeit verwalten. Deine%s ist in Aave hinterlegt Chart konnte nicht geladen werden... @@ -2230,18 +2352,18 @@ Meine Mittel Deine %1$s sind nun bei Aave angelegt und erwirtschaften Rendite. Du besitzt %2$s -Token, die Dein Guthaben repräsentieren und automatisch Rendite generieren. Bei jeder Aufladung wird Dein Aave-Konto zusätzliches Guthaben gutgeschrieben, um weitere Rendite zu erzielen (abzüglich Gebühren). Ertragsmodus - Gesamtverdienst + Gesamtertrag Übertragungen zu Aave Entdecke Aave Dies ist die aktuelle Liefergebühr auf %s. Die tatsächlichen Kosten werden auf der Registerkarte \"Aktivierung\" angezeigt. Aktuelle Gebühr - Alle zukünftigen %s-Einzahlungen werden automatisch an Aave geliefert, wobei die Transaktionsgebühr abgezogen wird. + Alle zukünftigen %s Das Guthaben wird Aave automatisch gutgeschrieben, nachdem die Transaktionsgebühr abgezogen wurde. Von jeder zukünftigen Aufladung wird eine ungefähre Netzwerkgebühr von %1$s ( %2$s ) abgezogen, die Dein Limit von %3$s ( %4$s ) nicht überschreiten wird. Wenn die Netzwerkgebühren über die maximale Gebühr steigen, wird die Transaktion erst durchgeführt, wenn diese sinken. Du kannst dieses Limit später ändern. Maximale Gebühr Der Mindestbetrag wird auf Grundlage der aktuellen Netzwerkgebühr berechnet, sodass er 4%% des Aufladebetrags nicht überschreitet, was einen Mindestbetrag von %1$s (%2$s) ergibt. Mindestaufladung - Gebührenpolitik + Gebührenregelung für Aufladungen Tangem erhebt außerdem eine Servicegebühr von 15% auf den erzielten Ertrag. Deine Gelder werden automatisch an Aave überwiesen, sobald die Netzwerkgebühren niedriger sind oder Dein Guthaben den erforderlichen Mindestbetrag erreicht. Die Gebühren sind aufgrund der hohen Marktaktivität derzeit höher als üblich. Du kannst jetzt fortfahren oder später noch einmal vorbeischauen, wenn die Gebühren niedriger sind. @@ -2252,13 +2374,13 @@ Token-Genehmigung erforderlich Prüfe Deine Netzwerkverbindung Informationen zu den Netzwerkgebühren nicht erreichbar - Jede Einzahlung, die Du tätigst, wird automatisch an Aave weitergeleitet. + Jede Aufladung wird automatisch an Aave übermittelt. Alle %1$s auf Ihrem Konto werden automatisch an Aave bereitgestellt. Automatische Übertragung zu Aave Senden, tauschen oder verkaufen Deine Gelder sofort, wann immer Du willst. Sofort verfügbar Wie funktioniert das? - Aave ist ein On-Chain-Protokoll zur Erstellung von nicht-kustodialen Liquiditätsmärkten, um Zinsen mit variablem Satz zu verdienen. + Aave ist ein On-Chain-Protokoll, das Non-Custodial-Liquiditätsmärkte bietet und es Nutzern ermöglicht, Renditen zu variablen Zinssätzen zu erzielen. Dezentral und selbstverwahrend Durch die Nutzung dieses Dienstes erklärst Du Dich mit provider\n%1$s und %2$s einverstanden Mit Aave verbinden @@ -2268,18 +2390,18 @@ Aave Durchschnitt %s Renditen des letzten Jahres - Der aktuelle Zinssatz ist immer variabel und wird automatisch vom Aave On-Chain-Smart-Contract auf der Grundlage von Angebot und Nachfrage in Echtzeit berechnet. + Der aktuelle Zinssatz ist stets variabel und wird automatisch vom On-Chain-Smart-Contract von Aave auf Basis von Angebot und Nachfrage in Echtzeit berechnet. Unterstützt durch Der Zinssatz ist variabel - Wenn Du dein Guthaben auflädst, wird es automatisch an Aave weitergeleitet, um Zinsen zu verdienen. zur Deckung der Transaktionsgebühr wird ein Betrag von %s abgezogen. + Wenn du dein Guthaben auflädst, wird es automatisch an Aave weitergeleitet, um Zinsen zu verdienen. zur Deckung der Transaktionsgebühr wird ein Betrag von %s abgezogen. Vermögenswerte liefern - Dein %s wird an Aave übermittelt, bleibt aber verwaltbar. - Siehe Gebührenrichtlinie - Deine nächste Aufladung wird automatisch an Aave weitergeleitet. + Dein %s wird Aave ohne Abschließmöglichkeiten zur Verfügung gestellt und bleibt uneingeschränkt zugänglich. + Siehe die Gebührenrichtlinien für Aufladungen. + Deine nächste Aufladungen werden automatisch an Aave übermittelt. Alle Ihre zukünftigen eingehenden %1$s-Einlagen werden automatisch an Aave bereitgestellt. Aktiv Pausiert - Deaktiviere den Yield-Modus + Deaktiviere den Ertragsmodus Wenn Du diese Option deaktivierst, werden Deine Vermögenswerte von Aave abgezogen, in Deiner Wallet wieder in %s umgewandelt und die Zinsgutschrift gestoppt. Eine Netzwerkgebühr wird von der Blockchain erhoben, wenn Sie den Yield-Modus verlassen. Deaktiviere den Yield-Modus @@ -2289,7 +2411,8 @@ Zinsen fallen automatisch an. Zinsen fallen automatisch an Ertragsmodus - Bearbeitung Deiner Einzahlung + Aktivierung des Ertragsmodus + Renditemodus - %1$s%% APY Ertragsmodus Yield-Mode-Vertragsbereitstellung Ertragsmodus aktivieren @@ -2305,6 +2428,6 @@ Einzahlung von %1$s %2$s zur Deckung der Netzwerkgebühr für Transaktionen Die Gebühr %s kann nicht gedeckt werden Der Yield-Modus ist momentan nicht verfügbar. Bitte versuche es später erneut. - Ausweichmodus nicht verfügbar + Yield Mode nicht verfügbar Chart konnte nicht geladen werden... diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index f7bc36a8d3..eaea4ea109 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -309,6 +309,7 @@ Hace %dh Importe + en En progreso Fondos insuficientes Más tarde @@ -394,6 +395,7 @@ A A %s Hoy + Token Token para enviar %d token @@ -644,6 +646,8 @@ Mantiene sus criptomonedas seguras y sin conexión. Tan delgadas como una tarjeta de crédito, más seguras que una bóveda bancaria. Si lo hace, tendrá que empezar de nuevo. Recuperar la billetera existente a través de una copia de seguridad de Google Drive + Estamos trabajando en la copia de seguridad de Google Drive para facilitar aún más la recuperación de la billetera. + Próximamente, copia de seguridad de Google Drive Copia de seguridad de Google Drive Cree una billetera segura y transfiera sus fondos para mayor protección. Crear nueva billetera @@ -983,6 +987,11 @@ Por favor repita la operación. La tarjeta/anillo se restablecerá a la configuración de fábrica. Error de activación Agregar tokens + + Su wallet contiene %d token. Para continuar, por favor sincronice sus direcciones. + Su wallet contiene %d tokens. Para continuar, por favor sincronice sus direcciones. + + Sincroniza tu wallet Ha añadido una tarjeta o anillo como copia de seguridad. Una vez finalizada la copia de seguridad, no puedes añadir más dispositivos. Si tiene una tarjeta o anillo más, añádalo ahora. ¿Quiere continuar? La copia de seguridad está parcialmente completa y no se puede salir ahora. Una frase de contraseña es una función de seguridad opcional que añade una palabra o frase a su frase de recuperación, creando un nuevo conjunto de direcciones de billetera para una mayor protección. @@ -1004,6 +1013,7 @@ Otras opciones Sus claves se generarán de forma segura dentro del chip. No hay seed phrase, lo que significa que nadie puede exportarla ni robarla. Generar claves de forma privada + Al continuar, acepta las \n%s Su tarjeta está activada y lista para usar ¡Éxito! ¡Su billetera está configurada y lista para usar! @@ -1508,13 +1518,21 @@ Siéntase seguro con una asistencia permanente que le ayudará con cualquier problema Siempre aquí Múltiples proveedores de confianza en un solo lugar: intercambie cualquier activo sin esfuerzo en su billetera + Intercambie criptos directamente en Tangem\nSin transferencias adicionales\nSin mover fondos a exchanges Intercambie con nosotros + Intercambia dentro de tu billetera Sin cuelgues, sin pérdidas, sin puntos ciegos - su transacción siempre está protegida + Los intercambios se realizan a través de proveedores de confianza. Sus llaves permanecen en su billetera Tangem en todo momento. Claro. Transparente. Autocustodiado. Defensa impenetrable + Ud mantiene el control Maximice su valor con tarifas de una amplia red de proveedores de confianza, eligiendo siempre la mejor + Tangem compara múltiples proveedores, tanto DEX como CEX. La mejor tarifa se selecciona automáticamente. ¿Prefiere otro proveedor? Puede elegirlo manualmente. Tarifas inmejorables + Mejor tarifa disponible Sencillo e intuitivo, permite cambiar tokens con solo unos cuantos toques + Intercambie entre las principales redes y miles de tokens 0% Comisión por intercambios entre stablecoins Simplemente cómodo + 90+ Blockchains\n16.000+ Activos Intercambio a través del proveedor Sus activos La cantidad incluye:\n• Tarifas del proveedor de servicios\n• Tarifas de red por enviar %s desde el intercambio a la dirección del usuario. @@ -1587,7 +1605,7 @@ Código PIN creado CVC Error al cargar datos. Inténtalo de nuevo más tarde. - Caducidad + Expira Congelar tarjeta Ocultar detalles Ocultar @@ -2028,6 +2046,10 @@ Use su tarjeta o anillo para obtener una dirección para la red %d Use su tarjeta o anillo para obtener direcciónes para las redes %d + + Sincronice direcciones para obtener una dirección de la red %d + Sincronice direcciones para obtener direcciones de las redes %d + Faltan algunas direcciones La red no está disponible actualmente. Por favor, inténtalo de nuevo más tarde. La red no está disponible diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index 8faa21a926..e7e7f8b950 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -309,6 +309,7 @@ Il y a %dh Importez + dans En cours Plus tard En savoir plus @@ -392,6 +393,7 @@ À À %s Aujourd\'hui + Token %d token %d tokens @@ -624,6 +626,8 @@ Gardez vos cryptomonnaies en sécurité et hors ligne. Aussi fin qu\'une carte de crédit, plus sûr qu\'un coffre-fort bancaire. Si vous le faites, vous devrez recommencer depuis le début. Récupérer un portefeuille existant via la sauvegarde Google Drive + Nous travaillons actuellement sur la sauvegarde de votre portefeuille via Google Drive afin de faciliter encore davantage la restauration de celui-ci. + La sauvegarde via Google Drive sera bientôt disponible Sauvegarde Google Drive Créez un portefeuille sécurisé et transférez vos fonds pour bénéficier d\'une protection supplémentaire. Créer un nouveau portefeuille @@ -695,6 +699,9 @@ Posez pour scanner Tapez pour signer Posez la carte + Votre wallet est synchronisé et prêt.\nDes tokens sont manquants ? + Wallet importé avec succès + Restauration %d%% Vous avez mis à jour vos données biométriques, scannez votre carte ou bague pour entrer Votre solde doit être supérieur à la valeur des frais pour effectuer un transfert Solde insuffisant @@ -943,6 +950,7 @@ Veuillez répéter l\'opération. La carte sera réinitialisée aux paramètres d\'usine. Erreur d\'activation Ajouter des jetons + Synchronisez votre wallet Vous avez ajouté une carte de sauvegarde. Une fois le processus de sauvegarde terminé, vous ne pourrez plus ajouter de cartes de sauvegarde. Si vous avez une autre carte, ajoutez-la à la sauvegarde. Souhaitez-vous poursuivre le processus de sauvegarde ? Le processus de sauvegarde est partiellement terminé. Vous ne pouvez pas le quitter maintenant. La phrase secrète est une fonctionnalité de sécurité avancée utilisée par les portefeuilles cryptographiques. Il ajoute un mot ou une phrase supplémentaire de votre choix à votre phrase de récupération déjà existante pour débloquer un tout nouvel ensemble d\'adresses. @@ -1457,13 +1465,21 @@ Ayez confiance en notre assistance 24 heures sur 24 pour vous aider à résoudre tous vos problèmes Assistance 24 heures sur 24 Plusieurs fournisseurs de confiance en un seul endroit : échangez n\'importe quel actif facilement + Échangez des cryptos directement dans Tangem\nSans transferts supplémentaires\nSans envoyer vos fonds vers un échange Échangez Avec Nous + Échangez Dans Votre Portefeuille Une sécurité de haut niveau et des fournisseurs vérifiés garantissant que vos actifs sont protégés à chaque échange + Les échanges sont effectués par des prestataires de confiance. Vos clefs ne quittent jamais votre portefeuille Tangem. Clair. Transparent. Gestion autonome Plus Sûr Que Jamais + Vous Gardez le Contrôle Maximisez votre valeur avec des tarifs d\'un large réseau de fournisseurs de confiance + Tangem compare plusieurs fournisseurs, DEX et CEX. Le meilleur tarif est sélectionné automatiquement. Vous préférez un autre fournisseur? Vous pouvez le choisir manuellement. Meilleurs Tarifs + Meilleur Tarif Disponible Simple et intuitif, vous permettant d\'échanger des jetons en quelques clics + Échangez des milliers de jetons sur les principaux réseaux 0% de frais sur les échanges stablecoin à stablecoin Plus Simple Que Jamais + 90+ Blockchains\n16,000+ Actifs Échange via le fournisseur Vos actifs Le montant comprend :\n• les frais du fournisseur de services\n• les frais de réseau pour l\'envoi de %s depuis l\'échange vers l\'adresse de l\'utilisateur. diff --git a/core/res/src/main/res/values-it/strings.xml b/core/res/src/main/res/values-it/strings.xml index 1b7dd158fb..45d98693f9 100644 --- a/core/res/src/main/res/values-it/strings.xml +++ b/core/res/src/main/res/values-it/strings.xml @@ -16,12 +16,14 @@ Fatto Errore Impossibile ottenere la commissione + in Costi della rete OK Mantieni le modifiche Invia Impossibile inviare la transazione Con successo + Token %d gettone %d gettoni diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index b500909414..62c071a8af 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -80,6 +80,9 @@ トークンの追加 受け取りたいトークンを選択します スワップしたいトークンを選択します + 資金を追加 + スワップ + 送金 ポートフォリオに追加 トークンを追加 並べ替え・グループ化 @@ -87,6 +90,10 @@ ネットワークを選択 カスタムトークンの追加 トークンの管理 + クレジットカードまたは銀行口座 + アドレスまたはQRコードを共有 + 自分のポートフォリオ間で + 受け取る このアドレスには%3$s ネットワークから%1$s (%2$s) のみを送信してください。他のトークンやネットワークを使用すると、資金を失う可能性があります。 デフォルト レガシー @@ -214,6 +221,7 @@ アクセスが拒否されました アカウント アカウント + %s に失敗しました 有効化 追加 資金を追加 @@ -230,6 +238,8 @@ 適用する 承認 承認 + 承認済み + 承認中 注意 利用可能なネットワーク バックアップ @@ -309,10 +319,14 @@ 非表示 %sまで長押し 時間 + + %d 時間 + %d時間前 インポート + 進行中 残高不足 後で @@ -355,6 +369,8 @@ %1$s — %2$s 続きを読む 受け取る + 受け取り済み + 受け取り中 おすすめ 拒否 リロード @@ -373,6 +389,8 @@ 送る 送金: 取引の送信に失敗しました + 送金中 + 送金済み サーバーが利用できません。しばらくしてからもう一度お試しください。 共有 リンクを共有 @@ -383,6 +401,7 @@ スキップ 問題が発生しました ステーキング + ステーキング済み ステーキング 始める 送信 @@ -390,6 +409,8 @@ サポート 対応ネットワーク スワップ + スワップ済み + スワップ中 Tangem Tangem Wallet タップして長押し @@ -398,6 +419,7 @@ 宛先 %sへ 今日 + トークン 送信するトークン %d トークン @@ -406,6 +428,8 @@ 取引状況 取引 送金 + 送金済み + しばらくしてからもう一度お試しください データを読み込めません… わかりました 理解して続行 @@ -415,10 +439,12 @@ ステーキング解除 %1$sの制限により、1つのトランザクションに収まるUTXOは%2$d個のみです。つまり、 %3$s以下しか送信できません。量を減らす必要があります。 値がコピーされました + 投票 ウォレット 警告 + 引き出し中 はい 利息モード コントラクトアドレスをコピーしました! @@ -580,12 +606,14 @@ ベストレート FCA警告リスト 固定レートは利用できません + スワッププロバイダー お得なレート FCA警告リストに掲載されたプロバイダー 最大 %s まで使用可能 %s 以上で利用可能 このペアは利用できません 許可が必要です + 権限が必要です 推奨 %sを買い付けました %sを買い付けています @@ -633,6 +661,7 @@ 承認機能は、別のアドレスに特定の量のトークンを使用する許可を与えるために必要です。設計上スマートコントラクトは、承認しない限りトークンにアクセスできません。トークンを「ロック解除」すると、StakeKitスマート コントラクトがトークンを使用する権限が与えられます。ネットワークのマイナーは、このアクションをブロックチェーンに記録するためにガス料金(あなたが支払う)を受け取ります。承認後、トークンをステーキングできます。 続行するには、Polygonスマートコントラクトが%sを使用することを許可する必要があります 続行するには、%1sスマートコントラクトに%2sを使用する権限を付与してください + 分散型取引所がウォレットと連携するには、許可が必要です。%1s 許可を与える 無制限 アドレスはTangemハードウェアウォレット上で直接生成され、そのまま安全に使用できます。 @@ -660,6 +689,8 @@ 暗号資産をオフラインで厳重保管。カードサイズで、金庫以上の安心を。 実行すると、最初からやり直す必要があります。 Googleドライブのバックアップから既存のウォレットを復元する + ウォレットの復元をさらに簡単にするため、Googleドライブバックアップ機能を準備しています。 + Googleドライブバックアップは近日対応予定です Googleドライブのバックアップ さらに強固なセキュリティのために、新しいウォレットを作成して資産を移動しましょう。 新しいウォレットを作成 @@ -1022,6 +1053,7 @@ その他のオプション 秘密鍵はチップ内で安全に生成されます。シードフレーズは存在しないので、誰もエクスポートしたり盗んだりすることはできません。 秘密鍵を非公開で生成する + 続行すると、以下に同意したものとみなされます。 カードは有効化され、使用可能になりました 成功! ウォレットの設定が完了し、使用できるようになりました。 @@ -1103,6 +1135,8 @@ 別の方法を選択してください。 現地の規制要件に準拠するため、%@の利用には本人確認が必要です。 決済プロバイダーによる本人確認が必要です。 + 認証する + 重要事項 オンランプ機能を使用することにより、プロバイダの%1$sおよび%2$sに同意するものとします サービスは外部プロバイダーによって提供されます。 \nTangemは責任を負いません。 買付金額は%s以下にしてください @@ -1166,7 +1200,11 @@ 不明なパラメータ クレジットカードまたは銀行口座 アドレスまたはQRコードを共有してください + 暗号資産を安全に売却 + 別のウォレットに送信 ポートフォリオ間で + その他 + クイック入金 メモ不要 %3$sネットワーク上の%1$s ( %2$s ) %2$sネットワーク上の%1$s @@ -1410,6 +1448,7 @@ ステーキングから資金の引き出しを要求した後、トークンが利用可能になるまでの待機期間。 ウォームアップ期間 ステーキングへの参加を有効にするために割り当てられた時間。 + ステーキングが有効です 現在、利用可能なバリデーターは見つかりません。しばらくしてからもう一度お試しください。 ステーキングは利用できません ネットワークは、ステーキングのためにトークンの使用を承認していることを確認するために、トークン承認手数料を請求します。 @@ -1520,23 +1559,26 @@ 続行するには少なくとも%1$sの受信取引が必要です 残高不足 承認すると、このスマートコントラクトが今後の取引でトークンを利用できるようになります。 + 詳細モード 固定レート ネットワークは、あなたがトークンのスワップを承認していることを確認するために、トークン承認手数料を請求します。 + スワップ中 より多くのトークンをより良いレートで、ウォレット内にて直接交換します。 新しいスワッププロバイダーが利用可能になりました! 他のものをお探しですか?\n検索してみるか、別の暗号資産をチェックしてみましょう! どのトークンでも検索できます。まだ一覧に表示されていないものでも検索可能です。 必要なものは検索して見つけましょう。 + シンプルモード 24時間体制のサポートであらゆる問題に対応します。 いつもここに 複数の信頼できるプロバイダーが一箇所に集結。ウォレット内で様々な暗号資産を簡単に交換できます。 - Tangem内で暗号資産を直接交換\n追加の送金は不要\n取引所に資産を移す必要なし + Tangem内で暗号資産を直接交換できます\n追加の送金は不要\n取引所に資金を移す必要もありません ぜひスワップしてください ウォレット内でスワップ 失敗も死角もありません。取引は常に保護されます。 - スワップは信頼できるプロバイダーを通じて実行されます。秘密鍵は常にTangemウォレット内に保持されます。シンプル。透明。自己管理。 + スワップは信頼できるプロバイダーを通じて実行されます。秘密鍵は常にTangemウォレット内に保持されます。明確で、透明性が高く、自己管理型です。 難攻不落の防御 - 主導権はあなたの手にあります + 主導権はあなたの手に 幅広いネットワークの中から、常に最適なプロバイダーと料金レートを選択します TangemはDEXとCEXを含む複数のプロバイダーを比較し、最良レートを自動で選択します。別のプロバイダーを希望する場合は、手動で選択できます。 破格のレート @@ -1555,7 +1597,9 @@ すべての分散型取引所は、スマートコントラクトがあなたの許可なくウォレットにアクセスするのを防ぐために承認を必要とします。設計上、スマートコントラクトは承認なしでトークンにアクセスできません。トークンを「ロック解除」することで、あなたは1-inchのスマートコントラクトがトークンを使うことを承認します。ネットワークのマイナーは、このアクションをブロックチェーンに記録するためのガス料金(あなたが支払う)を受け取ります。承認後、トークンを交換することができます。 承認 手数料見積りエラーです。サポートにフィードバックをお送りください。 + 送信元 スワップする + 送信 選択したトークンをこの量を交換すると、価格に大きな影響が生じ、結果が減少します。 流動性が低いため、受取額が大幅に少なくなる可能性があります。金額を減らすか、別のプロバイダーをお試しください。 価格への影響が甚大です @@ -1564,11 +1608,14 @@ 許可を与える スワップ スワップ中… + 受け取り先 受け取る トークンを選択 利用不可 この取引に十分な流動性がありません。\n金額を減らすか、別のプロバイダーを選択してください。 取引額が大きすぎます + 送金 + 送金... 皆様からのフィードバックをお待ちしております Tangem Payのベータ版を公開しました カード名を変更できません @@ -1645,6 +1692,7 @@ カードを交換する 英字と数字のみ使用できます 無効な文字が含まれています + カード名 表示 詳細を表示 ポートフォリオ内のあらゆる資産をカードと交換 @@ -1657,6 +1705,7 @@ 現在、出金ができません 現在の処理が完了するまでは、スワップや新しい出金を開始することはできません。 出金処理中です + カード名 %s以上の金額を設定してください 限度額を設定できませんでした。もう一度お試しください 変更 @@ -1667,6 +1716,9 @@ 1日の上限を設定しました 1日の利用限度額 カード設定 + + %d枚のカード + PINコードを変更 忘れた場合はアプリに戻って確認できます。 %s 〜 %sの範囲で上限を設定 @@ -1681,8 +1733,15 @@ 無料のTangem Visaバーチャルカードを入手 Tangem Payを入手 サポートへ移動 + 新しいカード情報が生成されます。 + 発行手数料 + 発行手数料を支払うため、決済口座にUSDCを入金してください + 手数料を支払えません + 追加カードを発行しますか? + カードを発行 通常は最大で15分ほどかかります Tangemカードのセットアップ + 新しいデジタルカードを発行中 カードを発行しています カードは通常、5分以内に自動で発行されます。手動での審査が必要な稀な場合は、最大48時間かかることがあります。 Tangem Pay @@ -1699,6 +1758,8 @@ KYCブロックを非表示 申し訳ございませんが、 本人確認ができませんでした。 + 最大3枚までカードを保有できます。新しいカードを追加するには、いずれかのカードを削除してください。 + カード発行枚数の上限に達しました 無料のTangem Visaバーチャルカードを入手 日常の支払いにUSDCを利用 カードをGET @@ -1708,6 +1769,8 @@ 余計な費用なしで、表示金額のみを支払う あなたの住所や資産を開示することなく、個別の支払い用アカウントが作成されます。 他に類を見ないプライバシー + そして支払いカードを連携します + ウォレットを設定します 無料のTangem Payカードを数分でゲットしましょう Payサポート 支払いアカウント @@ -1728,6 +1791,7 @@ 現在、データを表示できませんが、カードでのお支払いは引き続きご利用いただけます。 \nPINコードの設定 カード無効化済み + カードを交換中 セッションの有効期限が切れました セッションを更新 日常の支払いにUSDCを利用 @@ -1757,7 +1821,7 @@ 承認が取り消されましたが、あなたの資金は引き続き利息モードです。操作を行うには、利息モードに移動し、再度承認を付与してください。 利用可能残高 合計残高 - 年間で最大%sを獲得 + 年利最大%s XPUBを生成する 非表示 このトークンをメイン画面から非表示にします。トークンの管理ページからいつでも再度追加できます。 @@ -1783,18 +1847,28 @@ 売却できません %sからのスワップは利用できません スワップはできません + 報酬を請求中 コントラクト: %s + 利息モードを無効化中 + ステーキングで獲得した金額 まだ取引はありません 取引履歴の読み込みに失敗しました。\n情報を更新するには、リロードボタンをクリックしてください。 + %%image%% %s から 複数のアドレス 現在、このブロックチェーンでは取引履歴はサポートされていません。しかしご心配なく!弊社で対応中です。その間、エクスプローラーで確認することができます。 オペレーション + 保留中 + 報酬を再ステーキングしました + 報酬の再ステーキング + ステーキング報酬 + %%image%% %s へ 対象:%s 送金元: %s 送金先: %s バリデーター: %s 通知は有効になっていますが、デバイスの設定で通知を許可するまでは機能しません。 取引通知 + 送金処理中 最小%s 最小取引金額は%1$sです。 Tronネットワークの人気トークンの手数料は高い可能性があります。TRXをステーキングすると、取引コストを削減できる可能性があります。 @@ -1826,7 +1900,9 @@ 受信取引の通知を受け取る 新しいプロモーション情報をいち早く入手しましょう 新しい機能や限定オファーへの早期アクセス。 + 価格変動アラート、製品ニュース、限定オファー 機能およびニュースのアップデート + オファー・最新情報 プッシュ通知を使用しますか? プッシュ通知を有効にすると、ウォレットに着金したときにアラートを受信できます。 取引を見逃さない @@ -2064,7 +2140,10 @@ MATICはPOLに移行中です。ただし、期限は設定されておらず、MATICはまだ廃止されていません。MATICトークンを引き続き安全に使用することも、POLに交換することもできます。 MATICからPOLへの移行 - %d ネットワークのアドレスを取得するために、カードまたはリングを利用してください + カードまたはリングを使って、%dネットワークのアドレスを取得します + + + アドレスを同期して、%dネットワークのアドレスを取得します 一部のアドレスが見つかりません 現在、ネットワークにアクセスできません。しばらくしてからもう一度お試しください。 diff --git a/core/res/src/main/res/values-pt-rBR/strings.xml b/core/res/src/main/res/values-pt-rBR/strings.xml index 890ff18a50..64d51c812e 100644 --- a/core/res/src/main/res/values-pt-rBR/strings.xml +++ b/core/res/src/main/res/values-pt-rBR/strings.xml @@ -80,6 +80,9 @@ Adicionar tokens Escolha o token que deseja receber. Escolha o token que deseja trocar. + Adicionar fundos + Trocar + Transferir Adicione ao seu portfólio Adicionar tokens Classificação e agrupamento @@ -87,6 +90,10 @@ Escolha a rede Adicionar token personalizado Gerenciar tokens + Cartão de crédito ou conta bancária + Compartilhe seu endereço ou código QR. + Entre seus portfólios + Você recebe Enviar somente %1$s (%2$s) de %3$s A rede não está vinculada a este endereço. O uso de outros tokens e redes pode resultar na perda de fundos. Padrão Legado @@ -217,6 +224,7 @@ Acesso negado Conta Contas + %s fracassado Ativar Adicionar Adicionar fundos @@ -233,6 +241,8 @@ Aplicar Aprovação Aprovar + Aprovado + Aprovando Atenção Redes disponíveis Backup @@ -315,11 +325,16 @@ Esconder Mantenha-se em %s hora + + %d hora + %d horas + UM OUTRO Importar + em Em andamento Saldo insuficiente Mais tarde @@ -364,6 +379,8 @@ %1$s — %2$s Leia mais Receber + Recebido + Recebendo Recomendado Rejeitar Recarregar @@ -382,6 +399,8 @@ Enviar Enviar: Falha ao enviar a transação + Enviando + Enviado O servidor não está disponível. Tente novamente mais tarde. Compartilhar Compartilhar link @@ -392,6 +411,7 @@ Pular Algo deu errado Stake + Estacado Apostas Começar Enviar @@ -399,6 +419,8 @@ Suporte Redes suportadas Trocar + Trocado + Troca Tangem Carteira Tangem Toque e segure @@ -407,6 +429,7 @@ Para Para %s Hoje + Token Token a ser enviado token @@ -416,6 +439,8 @@ Status da transação Transações Transferir + Transferido + Por favor, tente novamente mais tarde. Não foi possível carregar os dados… Eu entendo Entendi, continue. @@ -425,10 +450,12 @@ Unstake Devido a %1$s limitações apenas %2$d Os UTXOs podem caber em uma única transação. Isso significa que você só pode enviar %3$s ou menos. Você precisa reduzir a quantidade. Valor copiado + Votação Carteiras Aviso semana com + Retirada Sim Modo de rendimento Endereço do contrato copiado! @@ -590,6 +617,7 @@ Melhor tarifa Lista de advertências da FCA A tarifa fixa não está disponível. + Fornecedor para troca Tarifa competitiva Fornecedor na lista de advertências da FCA Disponível até %s @@ -671,6 +699,8 @@ Mantém suas criptomoedas seguras e offline. Fino como um cartão de crédito, mais seguro que um cofre de banco. Se fizer isso, terá que começar tudo de novo. Recupere sua carteira existente por meio do backup do Google Drive. + Estamos trabalhando no backup do Google Drive para facilitar ainda mais a recuperação da carteira. + O backup do Google Drive estará disponível em breve. Backup do Google Drive Crie uma carteira segura e transfira seus fundos para obter proteção adicional. Criar nova carteira @@ -1041,6 +1071,7 @@ Outras opções Suas chaves serão geradas com segurança dentro do chip. Não há frase mnemônica, o que significa que ninguém pode exportá-las ou roubá-las. Gere chaves de forma privada + Ao continuar, você concorda com os termos. %1$s Seu cartão está ativado e pronto para uso. Sucesso! Sua carteira está configurada e pronta para uso! @@ -1166,6 +1197,8 @@ via %s Você pagará Grupo + Agrupar por redes + Ordenar por saldo Por equilíbrio Organizar tokens Desagrupar @@ -1191,6 +1224,8 @@ Parâmetros desconhecidos Cartão de crédito ou conta bancária Compartilhe seu endereço ou código QR. + Venda criptomoedas com segurança + Enviar para outra carteira Entre seus portfólios Outro Recarga rápida @@ -1440,6 +1475,7 @@ O período que você deve aguardar após solicitar o saque de fundos do staking para que os tokens fiquem disponíveis. Período de aquecimento O período alocado para ativar a participação no staking. + Staking ativado Não há validadores disponíveis no momento. Tente novamente mais tarde. Staking indisponível A rede cobrará uma taxa de aprovação de token para verificar se você está autorizando o uso do seu token para staking. @@ -1550,6 +1586,7 @@ Uma transação de entrada de pelo menos %1$s é necessário prosseguir Fundos insuficientes Ao aprovar, você permite que o contrato inteligente utilize seus tokens em transações futuras. + Modo detalhado Taxa fixa A rede cobrará uma taxa de aprovação de token para verificar se você está autorizando o uso do seu token para a troca. Troca em andamento @@ -1558,12 +1595,13 @@ Procurando algo diferente?\n Experimente pesquisar ou explore outra criptomoeda! Pesquise qualquer token, mesmo que ele ainda não esteja na sua lista. Use a busca para encontrar o que você precisa. + Modo simples Sinta-se seguro com o suporte disponível 24 horas por dia, 7 dias por semana, para ajudá-lo com qualquer problema. Sempre aqui Diversos provedores confiáveis ​​em um só lugar — troque qualquer ativo facilmente em sua carteira. Troque criptomoedas diretamente na Tangem\nSem transferências adicionais\nSem movimentação de fundos para corretoras Troque conosco - Troque o conteúdo da sua carteira. + Troque dentro de sua carteira Sem erros, sem perdas de posse, sem pontos cegos — sua transação está sempre protegida. As trocas são executadas por meio de provedores confiáveis. Suas chaves permanecem em sua carteira Tangem o tempo todo. Clara. Transparente. Autocustódia. Defesa Impenetrável @@ -1575,7 +1613,7 @@ Sem complicações e intuitivo, permitindo que você troque tokens com apenas alguns toques Troca entre as principais redes e milhares de tokens 0% Taxa em trocas de stablecoins Simplesmente conveniente - Mais de 90 blockchains | Mais de 16.000 ativos + mais de 90 blockchains\n16.000+ ativos Trocar através do provedor Seus ativos O valor inclui:\n• taxa do provedor de serviços\n• taxa de rede para envio %s da central de distribuição de volta para o endereço do usuário. @@ -1603,6 +1641,8 @@ não disponível Liquidez insuficiente para esta transação. \n o valor ou escolha outro provedor. Negociação em grande escala + Transferir + Transferir... Teremos todo o prazer em receber seu feedback. O Tangem Pay agora está em versão beta. Não foi possível renomear o cartão. @@ -1679,6 +1719,7 @@ Substituir cartão Somente letras e números são permitidos. Caracteres inválidos + Nome do cartão Revelar Mostrar detalhes Troque qualquer ativo da sua carteira por um cartão. @@ -1691,6 +1732,7 @@ Retirada indisponível agora Você não pode iniciar uma troca ou um novo saque até que o atual seja concluído. Retirada em andamento + Nome do cartão Definir um limite a partir de %s Não foi possível definir o limite. Tente novamente. Mudar @@ -1701,6 +1743,10 @@ O limite diário está definido. Limite diário Configurações do cartão + + %d cartão + %d cartões + Alterar código PIN Volte ao aplicativo se você se esquecer. Defina um limite a partir de %s para %s @@ -1715,8 +1761,15 @@ Obtenha seu cartão virtual Visa Tangem grátis. Obtenha o Tangem Pay Acesse o Suporte + Isso gera um novo conjunto de detalhes do cartão. + Taxa de emissão + Deposite USDC na conta de pagamento para cobrir a taxa de emissão. + Não foi possível cobrir a taxa. + Emitir um cartão adicional? + Emitir cartão Geralmente leva até 15 minutos. Configurando seu cartão Tangem + Emissão de um novo cartão digital Emissão do seu cartão O cartão geralmente é emitido automaticamente em até 5 minutos. Em casos raros, se for necessária uma análise manual, o processo pode levar até 48 horas. Tangem Pay @@ -1733,6 +1786,8 @@ Ocultar bloco KYC Desculpe, não foi possível verificar. Seu perfil. + Você pode ter até 3 cartões. Exclua um para adicionar um novo. + Número máximo de cartões emitidos Obtenha seu cartão virtual Visa Tangem grátis. Use USDC para pagamentos do dia a dia. Obter cartão @@ -1742,6 +1797,8 @@ Pague exatamente o que você vê. Uma conta de pagamento separada será criada sem divulgar seus endereços e bens. Privacidade incomparável + E vincule um cartão de pagamento a ele. + Vamos configurar uma carteira. Obtenha seu cartão Tangem Pay gratuito em minutos. Suporte de Pay Conta de pagamento @@ -1762,6 +1819,7 @@ Não foi possível exibir os detalhes. No entanto, os pagamentos com cartão ainda estão funcionando. Defina o código PIN. Cartão desativado + Substituindo seu cartão Sessão expirada Restaurar acesso Use USDC para pagamentos do dia a dia. @@ -1817,18 +1875,28 @@ Não disponível para venda Não disponível para troca a partir de %s Indisponível para troca + Reivindicar recompensa contrato: %s + Desativar o modo Yield + Ganhos com participação Você ainda não possui nenhuma transação. Falha ao carregar o histórico de transações. Clique no botão \n para atualizar as informações. + de: %%image%% %s Vários endereços O histórico de transações ainda não é suportado nesta blockchain. Mas não se preocupe, estamos trabalhando nisso! Enquanto isso, você pode conferir no explorador. Operação + Pendente + Recompensas redefinidas + Recompensas por reconfiguração + Recompensa de staking + para: %%image%% %s para: %s de: %s para: %s validador: %s As notificações estão ativadas, mas não funcionarão até que você as permita nas configurações do seu dispositivo. Notificações de transação + Transferência em andamento Mínimo %s O valor mínimo da transação é %1$s. As taxas da rede Tron para tokens populares podem ser mais altas. Fazer staking de TRX pode ajudar a reduzir os custos de transação. @@ -1860,7 +1928,9 @@ Receba notificações de transações recebidas. Seja o primeiro a saber sobre novas promoções. Acesso antecipado a novas funcionalidades e ofertas exclusivas. + Alertas de alteração de preços, novidades sobre produtos e ofertas exclusivas. Atualizações de notícias e recursos + Ofertas e atualizações Você gostaria de usar notificações push? Ative as notificações push para receber alertas quando os fundos chegarem à sua carteira. Não perca nenhuma transação. @@ -2099,8 +2169,12 @@ O MATIC está sendo migrado para o POL. No entanto, não há prazo definido e o MATIC ainda não foi descontinuado. Você pode continuar usando o token MATIC com segurança ou trocá-lo pelo POL. Migração de MATIC para POL - Use seu Cartão ou Anel para obter o endereço de uma rede. - Use seu Cartão ou Anel para obter endereços de rede. + Use seu Cartão ou Anel para obter o endereço de uma rede %d. + Use seu Cartão ou Anel para obter endereços de redes %d. + + + Sincronizar endereços para obter um endereço de %d rede + Sincronizar endereços para obter endereços para %d rede Alguns endereços estão faltando. A rede está inacessível no momento. Tente novamente mais tarde. diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index b8e2e9e0e4..bfd2cb942b 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -80,6 +80,9 @@ Добавьте токены Выберите токен для получения Выберите токен для обмена + Пополнить + Обмен + Перевод Добавить в портфель Добавить токен Сортировка и группировка @@ -87,6 +90,9 @@ Выберите сеть Добавить токен Валюты + Поделитесь своим адресом или QR-кодом. + Между вашими портфелями + Вы получите Отправляйте только %1$s (%2$s) в сети %3$s на этот адрес. Использование другой сети может привести к утрате средств. По умолчанию Устаревший @@ -326,6 +332,12 @@ Скрыть Удерживайте, чтобы %s час + + %d час + %d часa + %d часов + %d часа + %dч назад %dч назад @@ -333,6 +345,7 @@ %dч назад Импортировать + в В процессе Недостаточный баланс Позже @@ -423,6 +436,7 @@ На На %s Сегодня + Токен Токен к отправке %d токен @@ -504,7 +518,7 @@ Подписано Отправить отзыв Подробности - У вас может быть только один мобильный кошелек. Вы можете апгрейдить его до аппаратного кошелька Tangem или добавить новый аппаратный кошелек. + Мобильный кошелек может быть только один. Перенесите его на аппаратный кошелек Tangem или добавьте новый аппаратный кошелек. Проверьте подключение с интернетом или переключитесь на другую сеть Условия использования Основной адрес @@ -681,6 +695,8 @@ Хранит вашу криптовалюту в безопасности и офлайн. Тонкая, как банковская карта — надёжнее банковского хранилища. Если вы это сделаете, придётся начать заново. Восстановить существующий кошелёк через резервную копию Google Drive + Мы работаем над резервным копированием в Google Drive, чтобы сделать восстановление кошелька еще проще. + Резервное копирование в Google Drive скоро появится Google Drive бэкап Создайте новый защищённый кошелёк и переведите свои средства для максимальной защиты. Создать новый кошелек @@ -1036,6 +1052,13 @@ Необходимо повторить операцию, при этом карта будет сброшена к заводским настройкам Ошибка активации Добавление токенов + + В вашем кошельке %d токен. Синхронизируйте адреса, чтобы продолжить. + В вашем кошельке %d токена. Синхронизируйте адреса, чтобы продолжить. + В вашем кошельке %d токенов. Синхронизируйте адреса, чтобы продолжить. + В вашем кошельке %d токенов. Синхронизируйте адреса, чтобы продолжить. + + Синхронизируйте ваш кошелек Вы добавили одну резервную карту или кольцо. После того, как процесс будет завершен, Вы больше не сможете добавить еще. Если у Вас есть еще одна карта или кольцо, добавьте ее в резервную копию. Хотите продолжить? Процесс резервного копирования почти завершен. Вы не можете выйти из него сейчас. Парольная фраза — это дополнительная функция безопасности, которая добавляет слово или фразу к вашей фразе восстановления, создавая новый набор адресов кошелька для дополнительной защиты. @@ -1057,6 +1080,7 @@ Другие опции Ваши ключи будут надежно сгенерированы внутри чипа. Никакой seed-фразы, а это значит, что никто не может экспортировать или украсть ее. Cоздавайте ключи приватно + Продолжая, вы соглашаетесь с \n%s Ваша карта активирована и готова к использованию Успешно! Ваш кошелек настроен и готов к использованию! @@ -1178,6 +1202,8 @@ через %s Вы заплатите Группы + Сгруппировать по сети + Сортировать по балансу По балансу Упорядочить токены Список @@ -1571,13 +1597,21 @@ Чувствуйте уверенность с круглосуточной поддержкой, готовой помочь в любой ситуации! Круглосуточная поддержка Надежные провайдеры в одном месте — обменивайте любые активы легко и быстро прямо в своем кошельке! + Обменивайте крипту прямо в Tangem\nНикаких лишних переводов\nНе нужно отправлять средства на биржи Обменивайте с нами + Обмен прямо в кошельке Высший уровень безопасности и проверенные партнёры защищают каждую транзакцию + Обмен осуществляется через надежных провайдеров. Ваши ключи всегда остаются в кошельке Tangem. Понятно. Прозрачно. Некастодиально. Полная защита + Всё Под Вашим Контролем Автоматически находим самые выгодные предложения среди проверенных провайдеров + Tangem сравнивает несколько провайдеров, как DEX, так и CEX. Лучший курс выбирается автоматически. Предпочитаете другого провайдера? Вы можете выбрать его вручную. Лучшие курсы + Лучший Доступный Курс Интуитивный обмен в пару касаний — без сложностей и ожидания + Обменивайте тысячи токенов между любыми сетями. Комиссия 0% на обмен стейблкоинов Проще простого + 90+ Блокчейнов\n16 000+ Активов Обмен через провайдера Ваши средства В сумму включено: \n• комиссия провайдера сервиса\n• комиссия сети за отправку %s от биржи обратно на адрес пользователя. @@ -1823,6 +1857,7 @@ валидатор: %s Уведомления включены, но не будут работать, пока вы не разрешите их в настройках устройства. Уведомления о транзакциях + Перевод в процессе Минимум %s Минимальная сумма транзакции равна %1$s. Комиссии сети Tron для популярных токенов могут быть выше. Стейкинг TRX может помочь снизить расходы на транзакции. @@ -1965,7 +2000,7 @@ Установить Код Доступа Настройки кошелька Tangem - Используйте %s или отсканируйте карту/кольцо, чтобы разблокировать доступ к вашему кошельку + Воспользуйтесь %s или отсканируйте карту/кольцо, чтобы разблокировать доступ к вашему кошельку Процесс выдачи разрешения уже в работе и скоро будет завершен Выдача разрешения Похоже, что процесс активации карт или кольца не был завершен корректно. Это могло быть вызвано проблемой взаимодействия с модулем NFC либо некорректным прикладыванием карты или кольца к телефону. Пожалуйста, обратитесь в нашу службу поддержки для уточнения деталей. @@ -2039,6 +2074,12 @@ Используйте вашу карту или кольцо, чтобы получить адреса для %d сетей Используйте вашу карту или кольцо, чтобы получить адреса для %d сетей + + Синхронизируйте адреса, чтобы получить адрес для %d сети + Синхронизируйте адреса, чтобы получить адреса для %d сетей + Синхронизируйте адреса, чтобы получить адреса для %d сетей + Синхронизируйте адреса, чтобы получить адреса для %d сетей + Некоторые адреса отсутствуют В данный момент сеть недоступна. Пожалуйста, попробуйте позже. Сеть недоступна diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index 0e1fecd93d..e246aefdb0 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -321,6 +321,7 @@ %d годин тому Імпортувати + у В процесі Пізніше Дізнатися більше @@ -408,6 +409,7 @@ До На %s Сьогодні + Токен %d токен %d токени @@ -644,6 +646,8 @@ Зберігає ваші криптовалюти в безпеці та в режимі офлайн. Тонкий, як кредитна картка, безпечніший за банківське сховище. Якщо ви це зробите, доведеться почати спочатку. Відновлення існуючого гаманця за допомогою резервної копії Google Диску + Ми працюємо над резервним копіюванням у Google Drive, щоб зробити відновлення гаманця ще простішим. + Резервне копіювання в Google Drive незабаром з\'явиться Google Диск бекап Створіть новий захищений гаманець і переведіть свої кошти для додаткового захисту. Створити новий гаманець @@ -715,6 +719,9 @@ Прикладіть, щоб відсканувати Прикладіть, щоб підписати Прикладіть картку або кільце + Ваш гаманець синхронізовано й готово до використання.\nНе бачите деякі токени? + Гаманець успішно імпортовано + Відновлення %d%% Ви оновили дані біометрії, відскануйте свою картку або кільце для входу Ваш баланс повинен перевищувати суму комісії для здійснення переказу Недостатньо балансу @@ -977,6 +984,13 @@ Необхідно повторити операцію, при цьому картка буде скинута до заводських налаштувань. Помилка активації Додати токени + + У вашому гаманці %d токен. Синхронізуйте адреси, щоб продовжити. + У вашому гаманці %d токени. Синхронізуйте адреси, щоб продовжити. + У вашому гаманці %d токенів. Синхронізуйте адреси, щоб продовжити. + У вашому гаманці %d токенів. Синхронізуйте адреси, щоб продовжити. + + Синхронізуйте свій гаманець Ви додали одну резервну картку або кільце. Після завершення процесу резервного копіювання ви не зможете додати їх більше. Якщо у вас є ще одна картка або кільце, додайте її до резервної копії. Бажаєте продовжити? Процес резервного копіювання частково завершено. Ви не можете вийти з нього зараз. Парольна фраза – це розширена функція безпеки, яку використовують криптогаманці. Вона додає додаткове слово або фразу на ваш вибір до вже існуючої seed - фрази, щоб розблокувати абсолютно новий набір адрес. @@ -998,6 +1012,7 @@ Інші опції Ваші ключі будуть надійно згенеровані всередині чіпу. Ніякої seed-фрази, а це означає, що ніхто не зможе її експортувати або вкрасти. Генеруйте ключі приватно + Продовжуючи, ви погоджуєтеся з \n%s Ваша картка активована та готова до використання Успішно! Ваш гаманець налаштований і готовий до використання! @@ -1504,13 +1519,21 @@ Надійна підтримка 24/7, щоб ваші фінансові операції були швидкими та надійними! Цілодобова підтримка Надійні провайдери дозволяють легко обмінювати активи, забезпечуючи повну безпеку у вашому гаманці + Обмінюйте крипту прямо в Tangem\nНіяких зайвих переказів\nНе потрібно відправляти кошти на біржі Обмінюйте з нами + Обмін прямо в гаманці Найвищий рівень безпеки та перевірені провайдери гарантують захист ваших активів при кожному обміні. + Обмін здійснюється через надійних провайдерів. Ваші ключі завжди залишаються в гаманці Tangem. Зрозуміло. Прозоро. Некастодіально. Безпечніше, ніж будь-коли + Усе Під Вашим Контролем Отримуйте максимальну вигоду з кращими курсами від надійних провайдерів + Tangem порівнює кілька провайдерів, як DEX, так і CEX. Найкращий курс обирається автоматично. Віддаєте перевагу іншому провайдеру? Ви можете обрати його вручну. Найкращі тарифи + Найкращий Доступний Курс Просто та зручно — обмін токенів в декілька дотиків + Обмінюйте тисячі токенів між будь-якими мережами. Комісія 0% на обмін стейблкоїнів Легше, ніж будь-коли + 90+ Блокчейнів\n16 000+ Активів Обмін через провайдера Ваші активи Сума включає: \n• комісію постачальника послуг\n• комісію мережі за відправлення %s з біржі назад на адресу користувача. @@ -1975,6 +1998,12 @@ Використовуйте вашу картку або кільце, щоб отримати адреси для %d мереж Використовуйте вашу картку або кільце, щоб отримати адреси для %d мереж + + Синхронізуйте адреси, щоб отримати адресу для %d мережі + Синхронізуйте адреси, щоб отримати адреси для %d мереж + Синхронізуйте адреси, щоб отримати адреси для %d мереж + Синхронізуйте адреси, щоб отримати адреси для %d мереж + Деякі адреси відсутні Мережа наразі недоступна. Будь ласка, спробуйте пізніше. Мережа недоступна diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 336648fd49..1369fb0631 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -80,6 +80,9 @@ 添加代币 选择您想要接收的代币 选择您要兑换的代币 + 增加资金 + 兑换 + 转账 添加到您的投资组合 添加代币 排序和分组 @@ -87,6 +90,10 @@ 选择网络 添加自定义代币 管理代币 + 信用卡或银行账户 + 分享您的地址或二维码 + 在您的投资组合之间 + 您将收到 只能从 %3$s 网络发送 %1$s (%2$s) 到此地址。使用其他代币和网络可能会导致资金损失。 默认 传统 @@ -214,8 +221,10 @@ 拒绝访问 账户 账户 + %s 失败 激活 添加 + 增加资金 添加到投资组合 添加代币 添加代币 @@ -229,6 +238,8 @@ 申请 批准 批准 + 已批准 + 批准 请注意 可用网络 备份 @@ -308,10 +319,14 @@ 隐藏 保持到 %s 小时 + + %d小时 + 小时之前 导入 + 进行中 余额不足 稍后 @@ -354,6 +369,8 @@ %1$s — %2$s 阅读更多 接收 + 已收到 + 接收中 推荐 拒绝 重新加载 @@ -372,6 +389,8 @@ 发送 发送: 交易发送失败 + 发送中 + 发送 服务器不可用,请稍后再试。 分享 分享链接 @@ -382,6 +401,7 @@ 跳过 出问题了 质押 + 已质押 质押 开始 提交 @@ -389,6 +409,8 @@ 支持 支持的网络 兑换 + 已兑换 + 兑换... Tangem Tangem钱包 点击并按住 @@ -397,6 +419,7 @@ 到 %s 今天 + 代币 要发送的代币 %d代币 @@ -405,6 +428,8 @@ 交易状态 交易 转让 + 已转账 + 请稍后再试。 无法加载数据…… 我明白 我明白,请继续 @@ -414,10 +439,12 @@ 取消抵押 由于 %1$s 的限制,一次交易只能发送 %2$d 个UTXO。这意味着您只能发送 %3$s 或更少。您需要减少金额。 通用值已拷贝 + 表决 钱包 警告 + 撤回 收益模式 合约地址已复制! @@ -506,6 +533,7 @@ 在其他地址发现了资金。启用动态地址即可访问这些地址。 在其他地址发现的资金 动态地址 + 一旦网络 %@ 中的待处理交易完成,即可进行动态地址管理 最佳机会 清除筛选 列表正在刷新,暂时为空。请稍后再查看。 @@ -578,12 +606,14 @@ 最佳汇率 FCA警告清单 固定利率不可用 + 兑换提供商 有竞争力的费率 被列入 FCA 警告名单的提供商 最多可 %s 可用 %s 此交易对不可用 需要许可 + 需要许可 推荐 已购买 %s 购买 %s @@ -631,6 +661,7 @@ 您需要使用“批准”功能来授权其他地址使用您指定数量的代币。根据设计,智能合约只有在您批准后才能访问您的代币。通过“解锁”您的代币,您授权 StakeKit 智能合约使用它们。网络矿工会收到 Gas 费(由您支付)来记录区块链上的此操作。批准后,您可以质押您的代币。 要继续,您需要允许 Polygon 智能合约使用您的 %s 要继续,请授权 %1s 智能合约使用您的 %2s + 去中心化交易所需要获得许可才能与您的钱包互动。 %1s 给予许可 无限制 地址直接在您的 Tangem 硬件钱包上生成,随时可用,并受到全面保护。 @@ -658,6 +689,8 @@ 让您的加密货币安全离线存储。轻薄如信用卡,安全胜过银行金库。 如果确定现在退出,您需要从头再来。 通过 Google 云端硬盘备份恢复现有钱包 + 我们正在改进 Google 云盘备份功能,使钱包恢复更加便捷。 + Google 云盘备份功能即将推出 Google 云端硬盘备份 创建一个安全的钱包并转移资金,以加强保护。 创建新钱包 @@ -819,6 +852,7 @@ 收益模式 质押是获取加密货币奖励的最简单方式。 %s 年利率最高可达 %s + 在另一个网络或帐户中 代币已添加 关于 %s @@ -1019,6 +1053,7 @@ 其他选项 您的密钥将在芯片内部安全生成,没有助记词,这意味着任何人都无法导出或窃取它。 私下生成密钥 + 如继续,即表示您同意 您的卡已激活,可以使用了。 成功! 您的钱包已设置完毕,可以使用了! @@ -1094,6 +1129,14 @@ 此交易已处理完毕,无需进一步操作。 获得最佳利率... 即时 + 验证是免费的,通常需要 1-2 分钟。 + Tangem无法获取您的身份信息,您直接与受监管的服务提供商共享数据。 + 通过验证后,即可完全访问该提供商的未来交易 + 选择其他方法 + 为遵守当地监管要求 %@ 需要进行身份验证。 + 支付提供商要求进行身份验证 + 验证 + 什么是重要的 使用 onramp 功能即表示您同意提供商的 %1$s 和 %2$s 服务由外部供应商提供。\nTangem对此不承担任何责任。 购买金额不应超过 %s @@ -1132,6 +1175,8 @@ 通过 %s 您将支付 分组 + 按网络分组 + 按余额排序 按余额 整理代币 取消分组 @@ -1157,7 +1202,11 @@ 未知参数 信用卡或银行账户 分享您的地址或二维码 + 安全出售加密货币 + 发送到另一个钱包 在您的投资组合之间 + 其他 + 快速充值 无需备忘录 %1$s (%2$s) 在 %3$s 网络 %1$s 在 %2$s 网络 @@ -1401,6 +1450,7 @@ 从质押账户提取资金后,您必须等待一段时间才能获得代币。 热身期 激活参与质押的指定时间。 + 已启用质押 目前没有可用的验证节点。请稍后再试。 质押功能不可用 网络将收取代币批准费,以验证您是否授权使用您的代币进行质押。 @@ -1511,13 +1561,16 @@ 至少需要有 %1$s 的转入交易才能继续进行 资金不足 批准后,您即允许智能合约在未来的交易中使用您的代币。 + 详细模式 固定利率 网络将收取代币批准费,以验证您是否授权使用您的代币进行兑换。 + 兑换中 直接在您的钱包中以更优惠的汇率兑换更多代币。 新增兑换服务提供商! 还在寻找其他代币?\n尝试搜索或探索其他加密货币! 搜索任何代币,即使它还不在你的列表中。 使用搜索查找所需内容 + 简易模式 我们提供全天候支持,让您安心无忧,任何问题都能得到帮助。 永远在这里 多个值得信赖的供应商汇聚一处——在您的钱包中轻松兑换任何资产 @@ -1546,7 +1599,9 @@ 所有去中心化交易所都要求用户授权,以防止智能合约未经许可访问您的钱包。根据设计,智能合约只有在您授权后才能访问您的代币。通过“解锁”您的代币,您授权 1-inch 智能合约使用这些代币。网络矿工会收到 Gas 费(由您支付)来记录区块链上的此操作。授权后,您可以兑换您的代币。 批准 费用估算错误。请联系客服反馈。 + 您发送自 您兑换 + 您发送 兑换如此数量的选定代币将对价格产生重大影响,并降低您的收益。 由于流动性低,您收到的资金可能会大大减少。请尝试较小的金额或另一个提供商。 价格影响大 @@ -1555,11 +1610,14 @@ 给予许可 兑换 互换... + 您收到 您收到 选择代币 无法使用 此交易流动性不足,请减少金额或选择其他供应商。 交易额过大 + 转账 + 转账... 我们非常乐意收到您的反馈。 Tangem Pay 现已进入测试阶段 无法重命名卡片 @@ -1636,6 +1694,7 @@ 重新发行卡片 只允许输入字母和数字。 无效字符 + 卡片名称 显示 显示详情 将您投资组合中的任何资产互换到卡片 @@ -1648,6 +1707,7 @@ 目前无法提款 当前提款交易完成之前,您无法发起新的提款交易或互换交易。 提款进行中 + 卡片名称 从 %s设定一个限额 我们无法设置限额,请稍后再试。 改变 @@ -1658,6 +1718,9 @@ 每日限额已设定 每日限额 卡片设置 + + %d卡片 + 更改PIN码 如果忘记了,请返回应用程序。 设置限额从 %s 到 %s @@ -1672,8 +1735,15 @@ 免费领取您的 Tangem Visa 虚拟卡 获取 Tangem Pay 前往支持页面 + 它会生成一组新的卡片详细信息。 + 发行费 + 将USDC存入支付账户以支付发行费用 + 无法支付费用 + 是否再发一张卡? + 发卡 通常需要最多15分钟 设置您的 Tangem 卡 + 发行新的数字卡 正在发放您的卡片 该卡通常会在 5 分钟内自动发放。极少数情况下,如果需要人工审核,则可能需要长达 48 小时。 Tangem Pay @@ -1690,6 +1760,8 @@ 隐藏 KYC 块 抱歉,我们无法验证 您的个人资料。 + 您最多可以添加 3 张卡片。删除一张即可添加新卡片。 + 最大发卡量 免费领取您的 Tangem Visa 虚拟卡 使用 USDC 进行日常支付 获取卡片 @@ -1699,6 +1771,8 @@ 实际支付金额与所示金额一致 将在不透露您的地址和资产的情况下创建一个单独的付款账户 无与伦比的隐私保护 + 并将其与支付卡关联。 + 我们将设置一个钱包。 几分钟内即可获得免费的 Tangem Pay 卡 支付支持 支付账户 @@ -1719,6 +1793,7 @@ 无法显示详细信息。但刷卡支付功能仍然可用。 设置 PIN 码 卡片已停用 + 更换您的卡片 会话已过期 恢复访问权限 使用 USDC 进行日常支付 @@ -1774,18 +1849,28 @@ 无法出售 无法从 %s兑换 无法兑换 + 领取奖励 合约: %s + 禁用收益模式 + 质押收益 您目前还没有任何交易记录。 加载交易历史记录失败。\n点击刷新按钮更新信息。 + %%image%%%s 多个地址 本区块链目前不支持交易历史记录。不过不用担心,我们正在努力!在此期间,您可以在资源管理器中查看。 操作 + 待定 + 奖励已再质押 + 奖励再质押 + 质押奖励 + %%image%%%s 为 %s 来自 %s 到: %s 验证节点: %s 通知功能已启用,但需要您在设备设置中允许通知才能生效。 交易通知 + 转账中 最少 %s 最低交易金额为 %1$s。 热门代币在波场网络上的交易费用可能较高。质押 TRX 或许有助于降低交易成本。 @@ -1817,7 +1902,9 @@ 获取新交易通知 抢先了解最新促销活动 抢先体验最新功能和专属优惠。 + 价格变动提醒、产品资讯和独家优惠 专题报道和新闻更新 + 优惠与更新 您想使用推送通知吗? 启用推送通知,即可在资金到账时收到提醒。 不要错过任何一笔交易 @@ -2057,6 +2144,9 @@ 使用您的卡片或指环获取%d网络地址 + + 同步地址以获取%d网络地址 + 部分地址缺失 目前网络无法连接,请稍后再试。 网络无法访问 diff --git a/core/res/src/main/res/values-zh-rTW/strings.xml b/core/res/src/main/res/values-zh-rTW/strings.xml index 69ae0df956..bd779ef436 100644 --- a/core/res/src/main/res/values-zh-rTW/strings.xml +++ b/core/res/src/main/res/values-zh-rTW/strings.xml @@ -68,6 +68,7 @@ 錯誤 獲取費用失敗 導入 + 進行中 網路費 @@ -92,6 +93,7 @@ 成功 交換 條款和條件 + 代幣 %d 代幣 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 73aedfe1a8..3871825936 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -80,6 +80,9 @@ Add tokens Choose the token you want to receive Choose the token you want to swap + Add funds + Swap + Transfer Add to your portfolio Add tokens Sorting and grouping @@ -87,6 +90,10 @@ Choose network Add custom token Manage tokens + Credit card or bank account + Share your address or QR-code + Between your portfolios + You receive Send only %1$s (%2$s) from %3$s network to this address. Using other tokens and networks may result in loss of funds. Default Legacy @@ -217,6 +224,7 @@ Access denied Account Accounts + %s failed Activate Add Add funds @@ -233,6 +241,8 @@ Apply Approval Approve + Approved + Approving Attention Available networks Backup @@ -315,11 +325,16 @@ Hide Hold to %s hour + + %d hour + %d hours + %dh ago %dh ago Import + in In progress Insufficient balance Later @@ -364,6 +379,8 @@ %1$s — %2$s Read more Receive + Received + Receiving Recommended Reject Reload @@ -382,6 +399,8 @@ Send Send: Failed to send transaction + Sending + Sent The server is not available, please try again later Share Share Link @@ -392,6 +411,7 @@ Skip Something went wrong Stake + Staked Staking Start Submit @@ -399,6 +419,8 @@ Support Supported networks Swap + Swapped + Swapping Tangem Tangem Wallet Tap and hold @@ -407,6 +429,7 @@ To To %s Today + Token Token to send %d token @@ -416,6 +439,8 @@ Transaction status Transactions Transfer + Transferred + Please try again later. Unable to load data… I understand I understand, continue @@ -425,10 +450,12 @@ Unstake Due to %1$s limitations only %2$d UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount. Value copied + Voting Wallets Warning week with + Withdrawing Yes Yield Mode Contract address copied! @@ -590,6 +617,7 @@ Best rate FCA Warning List Fixed rate is unavailable + Provider for swap Competitive rate Provider in FCA warning list Available up to %s @@ -1044,6 +1072,7 @@ Other options Your keys will be securely generated inside the chip. There is no seed phrase, which means nobody can export or steal it. Generate keys privately + By continuing, you agree to the \n%s Your card is activated and ready to be used Success! Your wallet is set up and ready to use! @@ -1133,12 +1162,16 @@ Service is provided by an external provider.\nTangem is not responsible. The purchase amount should be no more than %s The amount to buy must be at least %s + Cumulative transaction amount over %1s may require identity verification with %2s + By clicking Pay, you agree to %1s\'s %2s and %3s. No available providers for this currency Quickest processing Pay with Payment method Available up to %s Available from %s + US and UK issued cards can\'t be processed via this method. Provider may require additional identity verification + Provider requirements %d provider %d providers @@ -1169,10 +1202,19 @@ via %s You will pay Group + Group by networks + Sort by balance By balance Organize tokens Ungroup %s support + Product news, exclusive offers, and activity reminders. + Offers & Updates + Get notified about price changes for top market coins. + Price Alerts + Notification Settings + Real-time alerts for transactions, exchanges, and critical updates. + Transaction Alerts More info You can enable Notifications for Tangem in Settings. Enable Later @@ -1194,6 +1236,8 @@ Unknown Parameters Credit card or bank account Share your address or QR-code + Sell crypto securely + Send to another wallet Between your portfolios Other Quick top up @@ -1443,6 +1487,7 @@ The period you must wait after requesting to withdraw funds from staking before the tokens become available. Warmup period The allocated time for activating participation in staking. + Staking enabled No available validators at the moment. Please try again later. Staking Unavailable The network will charge a token approval fee to verify that you are authorizing the use of your token for the staking. @@ -1553,6 +1598,7 @@ An incoming transaction of at least %1$s is required to proceed Insufficient funds By approving, you allow the smart contract to use your tokens in future transactions. + Detailed mode Fixed Rate The network will charge a token approval fee to verify that you are authorizing the use of your token for the swap. Swap in progress @@ -1561,6 +1607,7 @@ Looking for something else?\nTry searching or explore another crypto! Search for any token, even if it’s not in your list yet. Use search to find what you need + Simple mode Feel confident with round-the-clock support to help with any issues Always Here Multiple trusted providers in one place—swap any asset effortlessly in your wallet @@ -1576,7 +1623,7 @@ Unbeatable Rates Best Available Rate Hassle-free and intuitive, allowing you to swap tokens in just a few taps - Swap across major networks
and thousands of tokens 0% fee on stablecoin-to-stablecoin swaps + Swap across major networks
and thousands of tokens 0% fee on stablecoin-to-stablecoin swaps Simply Convenient 90+ Blockchains\n16,000+ Assets Swap via provider @@ -1606,6 +1653,8 @@ not available Not enough liquidity for this trade.\nReduce the amount or choose another provider. Trade too large + Transfer + Transfer... We would be happy to receive your feedback Tangem Pay is now in beta Unable to rename card @@ -1657,7 +1706,7 @@ CVC Failed to load data. Try again later. Expiry - Freeze Card + Freeze card Hide details Hide Open Google Wallet @@ -1682,6 +1731,7 @@ Replace card Only letters and numbers are allowed Invalid characters + Card name Reveal Show details Swap any asset in your portfolio for card @@ -1705,6 +1755,10 @@ Daily limit is set Daily limit Card settings + + %d card + %d cards + Change PIN-code Come back to the app if you forget it. Set a limit from %s to %s @@ -1714,13 +1768,20 @@ Failed to issue card A technical error has occurred, please try again by clicking the button below. A technical error has occurred, please contact support. - Feature will be availible soon + Feature will be available soon You will be available to issue additional cards for your payment account Get your free Tangem Visa virtual card Get Tangem Pay Go to Support + It generates a new set of card details + Issue fee + Deposit USDC to payment account to cover the issuing fee + Unable to cover fee + Issue an additional card? + Issue card It usually takes up to 15 minutes Setting up your Tangem Card + Issuing a new digital card Issuing your card The card is usually issued automatically within 5 minutes. In rare cases, if manual review is required, it may take up to 48 hours. Tangem Pay @@ -1737,6 +1798,8 @@ Hide KYC block Sorry, we couldn\'t verify your profile. + You can have up to 3 cards. Delete one to add a new card. + Maximum Cards Issued Get your free Tangem Visa virtual card Use USDC for everyday payments Get card @@ -1746,6 +1809,8 @@ Pay exactly what you see A separate payment account will be created without disclosing your addresses and assets Unrivaled privacy + And link a payment card to it + We\'ll set up a wallet Get your free Tangem Pay Card in minutes Pay Support Payment account @@ -1796,7 +1861,8 @@ Approval has been revoked. Your funds remain in Yield mode. To perform actions, please go to Yield mode and grant permission again. Available balance Total balance - Earn up to %s a year + Up to %s APR + Up to %s APY Generate XPUB Hide You are about to hide this token from the main screen. You can add it back anytime through the manage tokens page. @@ -1822,18 +1888,28 @@ Unavailable to sell Unavailable for swap from %s Unavailable for swap + Claiming reward contract: %s + Disabling Yield mode + Earned from stake You don\'t have any transactions yet Failed to load transaction history.\nClick on reload button to update the information. + from: %%image%% %s Multiple addresses Transaction history is currently not supported for this blockchain. But don\'t worry, we\'re working on it! In the meantime you can check it in the explorer. Operation + Pending + Rewards restaked + Rewards restaking + Staking reward + to: %%image%% %s for: %s from: %s to: %s validator: %s Notifications are enabled but won\'t work until you allow notifications in your device settings. Transaction Notifications + Transfer in progress Minimum %s The minimum transaction amount is %1$s. Tron network fees for popular tokens can be higher. Staking TRX may help reduce transaction costs. @@ -1865,7 +1941,9 @@ Get notified of incoming transactions Be the first to know about new promotions Early access to fresh features and exclusive offers. + Price change alerts, product news, and exclusive offers Feature and News Updates + Offers & Updates Would you like to use\nPush-notifications? Enable push notifications to receive alerts when funds arrive in your wallet. Don\'t Miss a Transaction @@ -2105,7 +2183,11 @@ MATIC to POL Migration Use your card or ring to get an address for %d network - Use your card or ring to get an addresses for %d networks + Use your card or ring to get addresses for %d networks + + + Sync addresses to get an address for %d network + Sync addresses to get an addresses for %d networks Some addresses are missing The network is currently unreachable. Please try again later. @@ -2265,6 +2347,14 @@ No, send all Reduce by %s XTZ To avoid paying an increased commission the next time you top up your wallet, reduce the amount by %s XTZ + Activate Yield Mode for the first time and get up to 3x yield for your first 30 days + First month APR bonus + You get market yield + Bonus. Bonus is paid once in USDT or USDC within 14 days after the 30-day period ends. Available while promo budget lasts. Terms and conditions apply + Summary + Keep funds in Yield Mode for 30 consecutive days. Bonus is based on the yield you actually earn during that period + How to qualify + 3 × market yield for first 30 days\nMinimum eligibility: $1 of market yield accumulated for 30 days\nMaximum bonus: $50 + How much you get When Yield Mode is active, all future top-ups to this address will be supplied to Aave. You can still manage your funds freely. Your %s is supplied to Aave Supplying %1$s %2$s to Aave From 5fb1090653b0eddf8ef89758198207fd0b071db9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 May 2026 07:13:13 -0700 Subject: [PATCH 203/206] Updated on 2026-08-14 --- .../components/TangemPayCardPageScreenComponent.kt | 4 +--- .../tangempay/entity/TangemPayDetailsUM.kt | 2 +- .../model/TangemPayCardDetailsBlockModel.kt | 14 ++++++++------ .../tangempay/ui/TangemPayCardDetailsBlock.kt | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt index 87a347f661..938132dbc9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayCardPageScreenComponent.kt @@ -60,9 +60,7 @@ internal class TangemPayCardPageScreenComponent( TangemPayCardPageScreen( state = state, cardDetailsBlockComponent = cardDetailsBlockComponent, - cardDetailsState = cardDetailsState.copy( - isActive = !state.isReissueInProgress, - ), + cardDetailsState = cardDetailsState, modifier = modifier, ) bottomSheet.child?.instance?.BottomSheet() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index a86c20d4d4..52a72107d6 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -31,7 +31,7 @@ internal data class TangemPayCardDetailsUM( val isLoading: Boolean = false, val cardFrozenState: TangemPayCardFrozenState, val displayNameState: DisplayNameState?, - val isActive: Boolean = true, + val isActionsAvailable: Boolean = false, ) internal sealed interface DisplayNameState { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt index 4e97b58c7d..eac06a5a95 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardDetailsBlockModel.kt @@ -74,7 +74,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( private val showCardDetailsTimerJobHolder = JobHolder() init { - subscribeToCardNameChanges(cardId = params.params.config.cardId, userWalletId = params.params.userWalletId) + subscribeToCardChanges(cardId = params.params.config.cardId, userWalletId = params.params.userWalletId) subscribeToCardFrozenState() modelScope.launch { cardDetailsEventListener.event.collectLatest { event -> @@ -86,7 +86,7 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } } - private fun subscribeToCardNameChanges(cardId: String, userWalletId: UserWalletId) { + private fun subscribeToCardChanges(cardId: String, userWalletId: UserWalletId) { paymentAccountStatusSupplier.invoke(userWalletId) .onEach { state -> val status = state.value @@ -97,7 +97,11 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( val card = status.requireCardWithId(cardId) val displayName = card.displayName ?: return@onEach + if (card.isReissuing) { + hideCardDetails() + } uiState.update(TangemPayCardDetailsUpdateNameTransformer(displayName)) + uiState.update { it.copy(isActionsAvailable = !card.isReissuing) } } } .launchIn(modelScope) @@ -141,10 +145,8 @@ internal class TangemPayCardDetailsBlockModel @Inject constructor( } fun hideCardDetails() { - modelScope.launch { - revealCardDetailsJobHolder.cancel() - uiState.transformerUpdate(transformer = DetailsHiddenStateTransformer(stateFactory)) - } + revealCardDetailsJobHolder.cancel() + uiState.transformerUpdate(transformer = DetailsHiddenStateTransformer(stateFactory)) } private fun showError() { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt index 18bf622b32..5a21bce025 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayCardDetailsBlock.kt @@ -97,7 +97,7 @@ internal fun TangemPayCard(state: TangemPayCardDetailsUM, modifier: Modifier = M shape = RoundedCornerShape(16.dp), ), ) { - if (shouldShowDetails && state.isActive) { + if (shouldShowDetails) { TangemPayCardDetailsShownBlock( cardNumber = state.number, expiry = state.expiry, @@ -147,7 +147,7 @@ private fun TangemPayCardDetailsHiddenBlock(state: TangemPayCardDetailsUM, modif ) } - if (state.isActive) { + if (state.isActionsAvailable) { ConstraintLayout( modifier = Modifier .align(Alignment.BottomCenter) From 99f228ee338413826f1d8d894f21b58c376e00c8 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 20 May 2026 08:04:22 -0700 Subject: [PATCH 204/206] Updated on 2026-08-14 --- ...rd_48x32.webp => img_visa_card_48_32.webp} | Bin .../drawable/img_visa_card_inactive_48_32.xml | 18 ++++++++++++++++ .../tangempay/ui/TangemPayDetailsScreen.kt | 20 +++++++----------- 3 files changed, 26 insertions(+), 12 deletions(-) rename core/ui/src/main/res/drawable/{img_visa_card_48x32.webp => img_visa_card_48_32.webp} (100%) create mode 100644 core/ui/src/main/res/drawable/img_visa_card_inactive_48_32.xml diff --git a/core/ui/src/main/res/drawable/img_visa_card_48x32.webp b/core/ui/src/main/res/drawable/img_visa_card_48_32.webp similarity index 100% rename from core/ui/src/main/res/drawable/img_visa_card_48x32.webp rename to core/ui/src/main/res/drawable/img_visa_card_48_32.webp diff --git a/core/ui/src/main/res/drawable/img_visa_card_inactive_48_32.xml b/core/ui/src/main/res/drawable/img_visa_card_inactive_48_32.xml new file mode 100644 index 0000000000..70bce64243 --- /dev/null +++ b/core/ui/src/main/res/drawable/img_visa_card_inactive_48_32.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 7044edc626..589b8d751c 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -302,20 +302,16 @@ private fun TangemPayCardItem(card: TangemPayDetailsBalanceBlockState.Card, modi ) { Image( modifier = Modifier.fillMaxSize(), - painter = painterResource(R.drawable.img_visa_card_48x32), + painter = painterResource( + if (card.isReissuing) { + R.drawable.img_visa_card_inactive_48_32 + } else { + R.drawable.img_visa_card_48_32 + }, + ), contentDescription = null, ) - if (card.isReissuing) { - Icon( - modifier = Modifier - .size(16.dp) - .align(Alignment.BottomStart) - .padding(2.dp), - painter = painterResource(R.drawable.ic_update_32), - contentDescription = null, - tint = TangemTheme.colors.text.constantWhite, - ) - } else { + if (!card.isReissuing) { Text( modifier = Modifier .align(Alignment.BottomStart) From a6bf9d2e2f0f6b8111877336536ce2854d304749 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 22 May 2026 14:03:52 +0300 Subject: [PATCH 205/206] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 76d48569f9..6741f7e5f8 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,7 +5,7 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.38-1512" +tangemBlockchainSdk = "releases-5.38-1523" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds tangemCardSdk = "releases-5.38-615" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ From e181c5a1a7bca8781b469bd0ab7da02ae4dff492 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 25 May 2026 19:16:04 +0300 Subject: [PATCH 206/206] Updated on 2026-08-14 --- .../deeplink/DefaultDeeplinkLauncher.kt | 14 ++++++++++- .../tangem/common/uri/ExternalUrlValidator.kt | 11 +++++++-- .../common/uri/ExternalUrlValidatorTest.kt | 23 ++++++++++++++++++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt index ce74ae1064..0b6f9c503e 100644 --- a/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt +++ b/app/src/main/java/com/tangem/tap/common/deeplink/DefaultDeeplinkLauncher.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import androidx.core.net.toUri import com.tangem.common.routing.DeepLinkScheme +import com.tangem.common.uri.ExternalUrlValidator import com.tangem.core.navigation.deeplink.DeeplinkLauncher import com.tangem.core.navigation.url.UrlOpener import com.tangem.utils.logging.TangemLogger @@ -24,7 +25,18 @@ internal class DefaultDeeplinkLauncher( DeepLinkScheme.Tangem.scheme, DeepLinkScheme.WalletConnect.scheme, -> launchDeepLink(deeplinkUri) - DeepLinkScheme.Https.scheme -> launchDeeplinkOrOpenBrowser(deeplinkUri, link) + DeepLinkScheme.Https.scheme -> { + if (ExternalUrlValidator.isUriTrusted(link)) { + launchDeeplinkOrOpenBrowser(deeplinkUri, link) + } else { + TangemLogger.i( + """ + Untrusted HTTPS link dropped + |- Received URI: $deeplinkUri + """.trimIndent(), + ) + } + } else -> { TangemLogger.i( """ diff --git a/common/src/main/kotlin/com/tangem/common/uri/ExternalUrlValidator.kt b/common/src/main/kotlin/com/tangem/common/uri/ExternalUrlValidator.kt index 49785fb80e..467a7493f0 100644 --- a/common/src/main/kotlin/com/tangem/common/uri/ExternalUrlValidator.kt +++ b/common/src/main/kotlin/com/tangem/common/uri/ExternalUrlValidator.kt @@ -11,14 +11,21 @@ import java.net.URI */ object ExternalUrlValidator { - private val trustedHost: List = listOf("tangem.com") + private val trustedHosts: Set = setOf( + "tangem.com", + "www.tangem.com", + "buy.tangem.com", + "app.tangem.com", + "tangem.surveysparrow.com", + "feedback.tangem.com", + ) /** Check if [externalUri] is trusted */ fun isUriTrusted(externalUri: String): Boolean { return try { val uri = URI.create(externalUri) - uri.scheme == "https" && uri.host in trustedHost + uri.scheme == "https" && uri.host in trustedHosts } catch (e: Exception) { val exception = IllegalStateException("Failed to validate URI: $externalUri", e) diff --git a/common/src/testDebug/kotlin/com/tangem/common/uri/ExternalUrlValidatorTest.kt b/common/src/testDebug/kotlin/com/tangem/common/uri/ExternalUrlValidatorTest.kt index f03d64dde6..ab2e743adf 100644 --- a/common/src/testDebug/kotlin/com/tangem/common/uri/ExternalUrlValidatorTest.kt +++ b/common/src/testDebug/kotlin/com/tangem/common/uri/ExternalUrlValidatorTest.kt @@ -23,11 +23,32 @@ class ExternalUrlValidatorTest(private val model: Model) { @JvmStatic @Parameterized.Parameters fun data(): Collection = listOf( + // Trusted hosts — exact match Model(url = "https://tangem.com", expected = true), - Model(url = "https://tange.com", expected = false), + Model(url = "https://tangem.com/pricing/?promocode=tgapp20ups", expected = true), + Model(url = "https://www.tangem.com", expected = true), + Model(url = "https://app.tangem.com", expected = true), + Model(url = "https://buy.tangem.com/?promocode=NEWINAPP", expected = true), + Model(url = "https://feedback.tangem.com", expected = true), + Model(url = "https://tangem.surveysparrow.com/s/tangem-pay/tt-F8XXH", expected = true), + // Subdomains not on the list + Model(url = "https://express.tangem.com/v1/", expected = false), Model(url = "https://fake.tangem.com", expected = false), + Model(url = "https://join.tangem.com", expected = false), + // Sibling hosts on the same registrable parent + Model(url = "https://surveysparrow.com", expected = false), + Model(url = "https://fake.surveysparrow.com", expected = false), + // Suffix-injection attempts + Model(url = "https://tangem.com.attacker.com", expected = false), + Model(url = "https://faketangem.com", expected = false), + Model(url = "https://buy.tangem.com.attacker.com", expected = false), + // Wrong scheme Model(url = "http://tangem.com", expected = false), + Model(url = "http://buy.tangem.com", expected = false), + // Typos + Model(url = "https://tange.com", expected = false), Model(url = "http://tandem.com", expected = false), + // Garbage Model(url = "adawdawdassdw", expected = false), )